|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25522 cut 13] The migration-flag and attestation bodies of `ObjectQL`, |
| 5 | + * moved out of `engine.ts`: `supplyTenantKeyCertifiedResolver` (hand a driver |
| 6 | + * the "is this database certified" question), `isOrganizationOwnershipCertified` |
| 7 | + * (that question's answer), `readFileReferencesFlagRow` and |
| 8 | + * `isValueShapesMigrationVerified` (the two memoized flag readers), |
| 9 | + * `readMigrationFlagVerified` (the one `sys_migration` row read behind them), |
| 10 | + * `noteAdmittedValueShapeViolation` (tally a violation this boot admitted), |
| 11 | + * `announceOpenMigrationGates` (the once-per-boot open-gate advisory) and |
| 12 | + * `wasDatastoreCreatedFromEmpty` (did this process create its store). |
| 13 | + * |
| 14 | + * A MOVE, not a redesign. Each exported function below is the body of the |
| 15 | + * `ObjectQL` method of the same name as it stood, re-indented one level and |
| 16 | + * otherwise unedited. Each method's docblock stays on the method in |
| 17 | + * `engine.ts`, which is still the contract. ADR-0104 and ADR-0131 govern what |
| 18 | + * these bodies decide; their decisions are cited on those docblocks. |
| 19 | + * |
| 20 | + * What stays in `engine.ts`: `isFileReferencesMigrationVerified` (ADR-0104 |
| 21 | + * names it by path), `recordObservedDeviation` and `retractCreationAttestation` |
| 22 | + * (each writes through `this.update`), the bodies too short to pay for a host |
| 23 | + * (`haveFileColumnsMoved`, `readMigrationFlagMemoized`, |
| 24 | + * `readMigrationFlagRowMemoized`, `valueShapeViolationsAdmitted`, |
| 25 | + * `admittedViolationSink`, `invalidateDataMigrationFlags`), and every field |
| 26 | + * of the section, each declared where it was. |
| 27 | + * |
| 28 | + * ## How the moved bodies reach the class |
| 29 | + * |
| 30 | + * The shape #23050 set for `rest-server.ts`, applied per method as |
| 31 | + * `engine-driver-options.ts` applies it. The class keeps all eight methods |
| 32 | + * with name, modifiers, signature, docblock and position, and each body is |
| 33 | + * one literal of exactly the {@link MigrationFlagsHost} members that method |
| 34 | + * reads, plus a `.call`. The fields a body reads (`logger`, `_registry`, |
| 35 | + * `admittedValueShapeViolations`, `migrationGatesAnnounced`, `drivers`) go as |
| 36 | + * GETTERS, so each read happens when the body makes it, as it did. The one |
| 37 | + * field a body ASSIGNS, `migrationGatesAnnounced`, goes as a getter and |
| 38 | + * SETTER pair over the instance, so the write lands on the engine and the |
| 39 | + * next read, from either side, sees it. The methods they call go as |
| 40 | + * late-bound closures over the instance, so a double or spy set on the engine |
| 41 | + * is still the one a call reaches. |
| 42 | + * |
| 43 | + * This module imports the engine only as a TYPE, so there is no module cycle |
| 44 | + * at run time, and nothing re-exports it, so the published surface does not |
| 45 | + * move. |
| 46 | + */ |
| 47 | + |
| 48 | +import { |
| 49 | + DATA_MIGRATION_FLAG_OBJECT, |
| 50 | + FILE_REFERENCES_MIGRATION_ID, |
| 51 | + VALUE_SHAPES_MIGRATION_ID, |
| 52 | + ORGANIZATION_OWNERSHIP_MIGRATION_ID, |
| 53 | + ORGANIZATION_OWNERSHIP_SHAPE_GENERATION, |
| 54 | + organizationOwnershipShapeGenerationOf, |
| 55 | + isDataMigrationFlagVerified, |
| 56 | + hasMovedFileColumns, |
| 57 | +} from '@objectstack/spec/system'; |
| 58 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 59 | +import type { IDataDriver } from '@objectstack/core'; |
| 60 | +import { isMissingTableError } from '@objectstack/metadata/errors'; |
| 61 | +import { valueShapePostureSetByEnv, mediaPostureSetByEnv } from './validation/record-validator.js'; |
| 62 | +import type { AdmittedValueShapeViolation } from './validation/record-validator.js'; |
| 63 | +import type { MigrationFlagRead, ObjectQL } from './engine.js'; |
| 64 | + |
| 65 | +/** |
| 66 | + * The `ObjectQL` members the functions below read — every one of them, and no |
| 67 | + * other; each function's `this` is the `Pick` of the members IT reads. Each is |
| 68 | + * typed AS the class member (an indexed access on the class), so a change to a |
| 69 | + * member's type on the class is a change here too, and naming a member the |
| 70 | + * class does not have is a compile error. See the module header for which |
| 71 | + * members are getters, and which one also has a setter. |
| 72 | + */ |
| 73 | +export interface MigrationFlagsHost { |
| 74 | + readonly logger: ObjectQL['logger']; |
| 75 | + readonly _registry: ObjectQL['_registry']; |
| 76 | + readonly admittedValueShapeViolations: ObjectQL['admittedValueShapeViolations']; |
| 77 | + migrationGatesAnnounced: ObjectQL['migrationGatesAnnounced']; |
| 78 | + readonly drivers: ObjectQL['drivers']; |
| 79 | + isOrganizationOwnershipCertified: ObjectQL['isOrganizationOwnershipCertified']; |
| 80 | + findOne: ObjectQL['findOne']; |
| 81 | + readMigrationFlagRowMemoized: ObjectQL['readMigrationFlagRowMemoized']; |
| 82 | + readMigrationFlagMemoized: ObjectQL['readMigrationFlagMemoized']; |
| 83 | + recordObservedDeviation: ObjectQL['recordObservedDeviation']; |
| 84 | + retractCreationAttestation: ObjectQL['retractCreationAttestation']; |
| 85 | + objectHasMediaField: ObjectQL['objectHasMediaField']; |
| 86 | + objectHasCoveredValueField: ObjectQL['objectHasCoveredValueField']; |
| 87 | + readMigrationFlagVerified: ObjectQL['readMigrationFlagVerified']; |
| 88 | +} |
| 89 | + |
| 90 | +/** The body of `ObjectQL#supplyTenantKeyCertifiedResolver`; the method's docblock there is its contract. */ |
| 91 | +export function supplyTenantKeyCertifiedResolver( |
| 92 | + this: Pick<MigrationFlagsHost, 'isOrganizationOwnershipCertified' | 'logger'>, |
| 93 | + driver: IDataDriver, |
| 94 | +): void { |
| 95 | + const sink = driver as unknown as { |
| 96 | + setTenantKeyCertifiedResolver?: (resolve: () => Promise<boolean>) => void; |
| 97 | + }; |
| 98 | + if (typeof sink.setTenantKeyCertifiedResolver !== 'function') return; |
| 99 | + try { |
| 100 | + sink.setTenantKeyCertifiedResolver(() => this.isOrganizationOwnershipCertified()); |
| 101 | + } catch (e: any) { |
| 102 | + // A driver that did not take the resolver answers "not certified": it |
| 103 | + // keeps the two forms distinct and REPORTS a destructive recreate_index, |
| 104 | + // which is never applied unattended (safe-only boot reconcile, the |
| 105 | + // artifact gate, `os migrate apply --allow-destructive`). |
| 106 | + this.logger.debug('Driver declined the ADR-0131 certification resolver', { |
| 107 | + driverName: driver.name, |
| 108 | + error: e?.message ?? String(e), |
| 109 | + }); |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +/** The body of `ObjectQL#isOrganizationOwnershipCertified`; the method's docblock there is its contract. */ |
| 114 | +export async function isOrganizationOwnershipCertified( |
| 115 | + this: Pick<MigrationFlagsHost, '_registry' | 'findOne'>, |
| 116 | +): Promise<boolean> { |
| 117 | + if (!this._registry.getObject(DATA_MIGRATION_FLAG_OBJECT)) return false; |
| 118 | + try { |
| 119 | + const row: any = await this.findOne(DATA_MIGRATION_FLAG_OBJECT, { |
| 120 | + where: { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID }, |
| 121 | + context: { isSystem: true } as ExecutionContext, |
| 122 | + }); |
| 123 | + if (!row || row.id !== ORGANIZATION_OWNERSHIP_MIGRATION_ID) return false; |
| 124 | + const verified = isDataMigrationFlagVerified({ |
| 125 | + id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, |
| 126 | + last_run_at: String(row.last_run_at ?? ''), |
| 127 | + verified_at: row.verified_at == null ? null : String(row.verified_at), |
| 128 | + blocking: typeof row.blocking === 'number' ? row.blocking : Number(row.blocking ?? Number.NaN), |
| 129 | + }); |
| 130 | + return verified && organizationOwnershipShapeGenerationOf(row.details) >= ORGANIZATION_OWNERSHIP_SHAPE_GENERATION; |
| 131 | + } catch (error) { |
| 132 | + // A store whose ledger table does not exist yet has no certificate. |
| 133 | + if (isMissingTableError(error, DATA_MIGRATION_FLAG_OBJECT)) return false; |
| 134 | + throw error; |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +/** The body of `ObjectQL#readFileReferencesFlagRow`; the method's docblock there is its contract. */ |
| 139 | +export function readFileReferencesFlagRow( |
| 140 | + this: Pick<MigrationFlagsHost, 'readMigrationFlagRowMemoized'>, |
| 141 | +): Promise<MigrationFlagRead> { |
| 142 | + return this.readMigrationFlagRowMemoized( |
| 143 | + 'fileReferencesMigrationVerified', |
| 144 | + FILE_REFERENCES_MIGRATION_ID, |
| 145 | + '[value-shape] this deployment has verified the file-as-reference migration — ' + |
| 146 | + 'media value shapes are enforced and released field files may be collected ' + |
| 147 | + '(ADR-0104)', |
| 148 | + ); |
| 149 | +} |
| 150 | + |
| 151 | +/** The body of `ObjectQL#isValueShapesMigrationVerified`; the method's docblock there is its contract. */ |
| 152 | +export async function isValueShapesMigrationVerified( |
| 153 | + this: Pick<MigrationFlagsHost, 'readMigrationFlagMemoized'>, |
| 154 | +): Promise<boolean> { |
| 155 | + return this.readMigrationFlagMemoized( |
| 156 | + 'valueShapesMigrationVerified', |
| 157 | + VALUE_SHAPES_MIGRATION_ID, |
| 158 | + '[value-shape] this deployment has verified the value-shape scan — reference and ' + |
| 159 | + 'structured-JSON value shapes are enforced (ADR-0104)', |
| 160 | + ); |
| 161 | +} |
| 162 | + |
| 163 | +/** The body of `ObjectQL#readMigrationFlagVerified`; the method's docblock there is its contract. */ |
| 164 | +export async function readMigrationFlagVerified( |
| 165 | + this: Pick<MigrationFlagsHost, '_registry' | 'findOne' | 'logger'>, |
| 166 | + migrationId: string, |
| 167 | + verifiedLog?: string, |
| 168 | +): Promise<MigrationFlagRead> { |
| 169 | + if (!this._registry.getObject(DATA_MIGRATION_FLAG_OBJECT)) { |
| 170 | + return { verified: false, conclusive: false, columnsMoved: false }; |
| 171 | + } |
| 172 | + try { |
| 173 | + // [#20648] The row by primary key, through the single-row route. ⛔ Not |
| 174 | + // `find` with `limit: 1`: the SQL driver reads that as page one of a |
| 175 | + // walk, and this read runs at boot BEFORE `sys_migration` is registered |
| 176 | + // with the driver — so every boot of an existing deployment printed the |
| 177 | + // driver's "Paged read ... is NOT deterministic" warning for a lookup |
| 178 | + // that cannot return two rows. `findOne` is the route it exempts. |
| 179 | + const row: any = await this.findOne(DATA_MIGRATION_FLAG_OBJECT, { |
| 180 | + where: { id: migrationId }, |
| 181 | + context: { isSystem: true } as ExecutionContext, |
| 182 | + }); |
| 183 | + if (!row || row.id !== migrationId) { |
| 184 | + return { verified: false, conclusive: true, columnsMoved: false }; |
| 185 | + } |
| 186 | + const flag = { |
| 187 | + id: migrationId, |
| 188 | + last_run_at: String(row.last_run_at ?? ''), |
| 189 | + verified_at: row.verified_at == null ? null : String(row.verified_at), |
| 190 | + // A non-numeric count must read as "not zero", not as 0 — a bad |
| 191 | + // coercion lands on NaN, which fails the === 0 test. |
| 192 | + blocking: typeof row.blocking === 'number' ? row.blocking : Number(row.blocking ?? Number.NaN), |
| 193 | + // [#15989] Read here so both questions come off ONE row: an absent or |
| 194 | + // empty stamp is the JSON encoding, which is what every row written |
| 195 | + // before the column step existed holds. |
| 196 | + columns_moved_at: row.columns_moved_at == null ? null : String(row.columns_moved_at), |
| 197 | + }; |
| 198 | + const verified = isDataMigrationFlagVerified(flag); |
| 199 | + if (verified && verifiedLog) this.logger.info(verifiedLog); |
| 200 | + return { verified, conclusive: true, columnsMoved: hasMovedFileColumns(flag) }; |
| 201 | + } catch { |
| 202 | + // unreadable evidence → stay lenient, keep asking; and the columns read |
| 203 | + // as NOT moved, which is the encoding every deployment already writes. |
| 204 | + return { verified: false, conclusive: false, columnsMoved: false }; |
| 205 | + } |
| 206 | +} |
| 207 | + |
| 208 | +/** The body of `ObjectQL#noteAdmittedValueShapeViolation`; the method's docblock there is its contract. */ |
| 209 | +export function noteAdmittedValueShapeViolation( |
| 210 | + this: Pick<MigrationFlagsHost, 'admittedValueShapeViolations' | 'recordObservedDeviation' | 'retractCreationAttestation'>, |
| 211 | + object: string, |
| 212 | + violation: AdmittedValueShapeViolation, |
| 213 | +): void { |
| 214 | + const migrationId = |
| 215 | + violation.gate === 'media' ? FILE_REFERENCES_MIGRATION_ID : VALUE_SHAPES_MIGRATION_ID; |
| 216 | + const existing = this.admittedValueShapeViolations.get(migrationId); |
| 217 | + if (existing) { |
| 218 | + existing.count += 1; |
| 219 | + } else { |
| 220 | + this.admittedValueShapeViolations.set(migrationId, { |
| 221 | + count: 1, |
| 222 | + first: { object, field: violation.field, type: violation.type, detail: violation.detail }, |
| 223 | + }); |
| 224 | + } |
| 225 | + // Two consumers of the same counterexample, answering different questions. |
| 226 | + // The deviation marker goes first because it is the one that applies to |
| 227 | + // EVERY verified deployment (#4797); the retraction below applies only to |
| 228 | + // the narrow fresh-datastore case (#4769) and closes the gate outright |
| 229 | + // there. Each is the other's backstop if one write fails. |
| 230 | + this.recordObservedDeviation(migrationId); |
| 231 | + this.retractCreationAttestation(migrationId); |
| 232 | +} |
| 233 | + |
| 234 | +/** The body of `ObjectQL#announceOpenMigrationGates`; the method's docblock there is its contract. */ |
| 235 | +export async function announceOpenMigrationGates( |
| 236 | + this: Pick<MigrationFlagsHost, 'migrationGatesAnnounced' | '_registry' | 'objectHasMediaField' | 'objectHasCoveredValueField' | 'readMigrationFlagVerified' | 'logger'>, |
| 237 | +): Promise<void> { |
| 238 | + if (this.migrationGatesAnnounced) return; |
| 239 | + this.migrationGatesAnnounced = true; |
| 240 | + try { |
| 241 | + const mediaByEnv = mediaPostureSetByEnv(); |
| 242 | + const coveredByEnv = valueShapePostureSetByEnv(); |
| 243 | + if (mediaByEnv && coveredByEnv) return; |
| 244 | + |
| 245 | + let media = false; |
| 246 | + let covered = false; |
| 247 | + for (const obj of this._registry.getAllObjects() as any[]) { |
| 248 | + if (!media && !mediaByEnv && this.objectHasMediaField(obj)) media = true; |
| 249 | + if (!covered && !coveredByEnv && this.objectHasCoveredValueField(obj)) covered = true; |
| 250 | + if ((media || mediaByEnv) && (covered || coveredByEnv)) break; |
| 251 | + } |
| 252 | + |
| 253 | + // [#4769] Read the ledger, not the memo. This advisory runs once, at |
| 254 | + // `kernel:bootstrapped`, and the fresh-datastore attestation may have |
| 255 | + // written its rows moments earlier — after the boot's first write had |
| 256 | + // already memoized "not verified". Reporting from that memo tells a |
| 257 | + // brand-new deployment to run a migration whose gate is already closed. |
| 258 | + // One query per applicable gate, once per boot. |
| 259 | + if (media && !(await this.readMigrationFlagVerified(FILE_REFERENCES_MIGRATION_ID)).verified) { |
| 260 | + this.logger.info( |
| 261 | + '[value-shape] media values are checked but NOT enforced here, and released files are ' + |
| 262 | + 'never collected — this deployment has not verified its file migration. Run ' + |
| 263 | + '`os migrate files-to-references` (dry run) to see what it would do, then `--apply` ' + |
| 264 | + 'to close the gate (ADR-0104).', |
| 265 | + ); |
| 266 | + } |
| 267 | + if (covered && !(await this.readMigrationFlagVerified(VALUE_SHAPES_MIGRATION_ID)).verified) { |
| 268 | + this.logger.info( |
| 269 | + '[value-shape] reference and structured-JSON values are checked but NOT enforced here — ' + |
| 270 | + 'this deployment has not verified its value-shape scan. Run `os migrate value-shapes` ' + |
| 271 | + '(dry run) to see what it would report, then `--apply` to close the gate ' + |
| 272 | + '(ADR-0104).', |
| 273 | + ); |
| 274 | + } |
| 275 | + } catch { |
| 276 | + // An advisory must never be the reason a boot fails. |
| 277 | + } |
| 278 | +} |
| 279 | + |
| 280 | +/** The body of `ObjectQL#wasDatastoreCreatedFromEmpty`; the method's docblock there is its contract. */ |
| 281 | +export function wasDatastoreCreatedFromEmpty( |
| 282 | + this: Pick<MigrationFlagsHost, 'drivers'>, |
| 283 | +): boolean { |
| 284 | + let created = 0; |
| 285 | + let existing = 0; |
| 286 | + let reporting = 0; |
| 287 | + for (const driver of this.drivers.values()) { |
| 288 | + const stats = (driver as any).getSchemaSyncStats?.(); |
| 289 | + if (!stats) continue; |
| 290 | + reporting += 1; |
| 291 | + created += Number(stats.created) || 0; |
| 292 | + existing += Number(stats.existing) || 0; |
| 293 | + } |
| 294 | + return reporting > 0 && existing === 0 && created > 0; |
| 295 | +} |
0 commit comments