|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25522 cut 8] The compatibility-API bodies of `ObjectQL` (the methods |
| 5 | + * that match the legacy `@objectql/core` interface), moved out of |
| 6 | + * `engine.ts` whole: `registerObject` (a single definition into the |
| 7 | + * registry, field names filled from their keys), `getConfigs` (the |
| 8 | + * registered objects as a name-to-config map), `introspectDatasource` (a |
| 9 | + * datasource's live remote schema, ADR-0015), `syncSchemas` (every |
| 10 | + * registered object's storage synced to its driver, a federated object bound |
| 11 | + * without DDL), `syncObjectSchema` (the same for one object, on demand) and |
| 12 | + * `removePackage` (a package's hooks, actions and objects removed). |
| 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: the same registry calls, the same two refusals before |
| 17 | + * introspection, the same federated routing, the same durability ERROR when a |
| 18 | + * schema sync fails, and the same removal order. Each method's docblock stays |
| 19 | + * on the method in `engine.ts`, which is still the contract. |
| 20 | + * |
| 21 | + * What stays in the class: `getDriverByName`, because ADR-0096 cites it by |
| 22 | + * `engine.ts#getDriverByName`; `create`, a static factory; `createContext`, |
| 23 | + * which hands the engine itself to `ScopedContext`, a value `engine.ts` |
| 24 | + * defines (moving it would import the engine at run time); and |
| 25 | + * `unregisterObject`, `getObject`, `getDriverForObject`, |
| 26 | + * `dropObjectSchema`, `datasource`, `on` and `close`, whose bodies are no |
| 27 | + * longer than the host literal moving them would cost. |
| 28 | + * |
| 29 | + * ## How the moved bodies reach the class |
| 30 | + * |
| 31 | + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` |
| 32 | + * header carries the full reasoning), applied per method as |
| 33 | + * `engine-execute-transactions.ts` applies it. The class keeps all six |
| 34 | + * methods — every one is public, and the published `.d.ts` lists each one's |
| 35 | + * name, signature and position — and each body is one literal of exactly the |
| 36 | + * {@link CompatibilityApiHost} members that method reads, plus a `.call`. |
| 37 | + * The fields the bodies read (`_registry`, `drivers`, `hooks`, `logger`) |
| 38 | + * go as GETTERS, so each read happens when the body makes it, as it did. The |
| 39 | + * methods they call (`getDriverForObject`, `removeActionsByPackage`) 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 type { ServiceObject } from '@objectstack/spec/data'; |
| 49 | +import type { IntrospectedSchema as SpecIntrospectedSchema } from '@objectstack/spec/contracts'; |
| 50 | +import { StorageNameMapping } from '@objectstack/spec/system'; |
| 51 | +import { isFederatedObject } from './federated-object.js'; |
| 52 | +import type { ObjectQL } from './engine.js'; |
| 53 | + |
| 54 | +/** |
| 55 | + * The `ObjectQL` members the functions below read — every one of them, and no |
| 56 | + * other; each function's `this` is the `Pick` of the members IT reads. Each is |
| 57 | + * typed AS the class member (an indexed access on the class), so a change to a |
| 58 | + * member's type on the class is a change here too, and naming a member the |
| 59 | + * class does not have is a compile error. See the module header for which |
| 60 | + * members are getters. |
| 61 | + */ |
| 62 | +export interface CompatibilityApiHost { |
| 63 | + readonly _registry: ObjectQL['_registry']; |
| 64 | + readonly drivers: ObjectQL['drivers']; |
| 65 | + readonly hooks: ObjectQL['hooks']; |
| 66 | + readonly logger: ObjectQL['logger']; |
| 67 | + getDriverForObject: ObjectQL['getDriverForObject']; |
| 68 | + removeActionsByPackage: ObjectQL['removeActionsByPackage']; |
| 69 | +} |
| 70 | + |
| 71 | +/** The body of `ObjectQL#registerObject`; the method's docblock there is its contract. */ |
| 72 | +export function registerObject( |
| 73 | + this: Pick<CompatibilityApiHost, '_registry'>, |
| 74 | + schema: ServiceObject, |
| 75 | + packageId: string = '__runtime__', |
| 76 | + namespace?: string, |
| 77 | +): string { |
| 78 | + // Auto-assign field names from keys |
| 79 | + if (schema.fields) { |
| 80 | + for (const [key, field] of Object.entries(schema.fields)) { |
| 81 | + if (field && typeof field === 'object' && !('name' in field)) { |
| 82 | + (field as any).name = key; |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | + return this._registry.registerObject(schema, packageId, namespace); |
| 87 | +} |
| 88 | + |
| 89 | +/** The body of `ObjectQL#getConfigs`; the method's docblock there is its contract. */ |
| 90 | +export function getConfigs(this: Pick<CompatibilityApiHost, '_registry'>): Record<string, ServiceObject> { |
| 91 | + const result: Record<string, ServiceObject> = {}; |
| 92 | + const objects = this._registry.getAllObjects(); |
| 93 | + for (const obj of objects) { |
| 94 | + if (obj.name) { |
| 95 | + result[obj.name] = obj; |
| 96 | + } |
| 97 | + } |
| 98 | + return result; |
| 99 | +} |
| 100 | + |
| 101 | +/** The body of `ObjectQL#introspectDatasource`; the method's docblock there is its contract. */ |
| 102 | +export async function introspectDatasource( |
| 103 | + this: Pick<CompatibilityApiHost, 'drivers'>, |
| 104 | + datasource: string, |
| 105 | +): Promise<SpecIntrospectedSchema> { |
| 106 | + const driver = this.drivers.get(datasource); |
| 107 | + if (!driver) { |
| 108 | + throw new Error(`[ObjectQL] Datasource '${datasource}' has no registered driver to introspect.`); |
| 109 | + } |
| 110 | + if (typeof driver.introspectSchema !== 'function') { |
| 111 | + throw new Error(`[ObjectQL] Driver for datasource '${datasource}' does not support introspectSchema().`); |
| 112 | + } |
| 113 | + return driver.introspectSchema(); |
| 114 | +} |
| 115 | + |
| 116 | +/** The body of `ObjectQL#syncSchemas`; the method's docblock there is its contract. */ |
| 117 | +export async function syncSchemas( |
| 118 | + this: Pick<CompatibilityApiHost, '_registry' | 'getDriverForObject' | 'logger'>, |
| 119 | +): Promise<void> { |
| 120 | + const allObjects = this._registry.getAllObjects(); |
| 121 | + for (const obj of allObjects) { |
| 122 | + const driver = this.getDriverForObject(obj.name); |
| 123 | + if (!driver) continue; |
| 124 | + // [#21777] Same predicate and same treatment as the boot sync. The remote |
| 125 | + // owns this object's schema, so `syncSchema` would be refused |
| 126 | + // (`ExternalSchemaModeViolationError` on an external-schema datasource). |
| 127 | + // Logging that refusal at the #4632 ERROR below was a false durability |
| 128 | + // alarm, because nothing was meant to be created. The DDL-free binding |
| 129 | + // still runs, because an object registered at runtime has no other way |
| 130 | + // to its remote table: without it every read resolves to a table named |
| 131 | + // after the object. |
| 132 | + if (isFederatedObject(obj)) { |
| 133 | + if (typeof (driver as any).registerExternalObject !== 'function') { |
| 134 | + this.logger.debug('Driver does not support registerExternalObject, skipping external object', { |
| 135 | + object: obj.name, |
| 136 | + driver: (driver as any).name, |
| 137 | + }); |
| 138 | + continue; |
| 139 | + } |
| 140 | + try { |
| 141 | + await (driver as any).registerExternalObject(obj); |
| 142 | + this.logger.debug('Federated object is not a DDL target — bound to its remote table without DDL', { |
| 143 | + object: obj.name, |
| 144 | + driver: (driver as any).name, |
| 145 | + }); |
| 146 | + } catch (e: unknown) { |
| 147 | + this.logger.warn('Failed to register external object metadata', { |
| 148 | + object: obj.name, |
| 149 | + driver: (driver as any).name, |
| 150 | + error: e instanceof Error ? e.message : String(e), |
| 151 | + }); |
| 152 | + } |
| 153 | + continue; |
| 154 | + } |
| 155 | + const tableName = StorageNameMapping.resolveTableName(obj); |
| 156 | + if (typeof (driver as any).syncSchemasBatch === 'function' && (driver as any).supports?.batchSchemaSync) { |
| 157 | + // Already handled per-driver below; skip individual call |
| 158 | + } |
| 159 | + if (typeof (driver as any).syncSchema === 'function') { |
| 160 | + try { |
| 161 | + await (driver as any).syncSchema(tableName, obj); |
| 162 | + } catch (e: unknown) { |
| 163 | + // #4632 — this catch used to be empty, with the comment "log |
| 164 | + // suppressed to avoid noise on already-synced tables". Suppressing an |
| 165 | + // already-synced no-op is not what it did: `syncSchema` is required to |
| 166 | + // be idempotent (see this method's doc comment), so a driver that |
| 167 | + // reaches this catch did NOT sync. The only callers are runtime |
| 168 | + // installs — marketplace plugin install, template seeding — which go |
| 169 | + // on to INSERT into a table this failure means does not exist, and |
| 170 | + // then report the install as successful. Nothing that claims to be |
| 171 | + // persisted afterwards is. |
| 172 | + this.logger.error( |
| 173 | + `Schema sync FAILED for object '${obj.name}' — its table/collection was NOT created or altered, yet the object is ` + |
| 174 | + `registered and will be written to: those writes will fail, or drop the columns that were never created. ` + |
| 175 | + `Any seeding or install step that continues past this point is not durable. ` + |
| 176 | + `Fix the driver error below, then re-run the install/sync.`, |
| 177 | + e as Error, |
| 178 | + { object: obj.name, tableName, driver: (driver as any)?.name }, |
| 179 | + ); |
| 180 | + } |
| 181 | + } |
| 182 | + } |
| 183 | +} |
| 184 | + |
| 185 | +/** The body of `ObjectQL#syncObjectSchema`; the method's docblock there is its contract. */ |
| 186 | +export async function syncObjectSchema( |
| 187 | + this: Pick<CompatibilityApiHost, '_registry' | 'getDriverForObject'>, |
| 188 | + objectName: string, |
| 189 | +): Promise<void> { |
| 190 | + const obj = this._registry.getObject(objectName) as any; |
| 191 | + if (!obj) return; |
| 192 | + const driver = this.getDriverForObject(objectName); |
| 193 | + if (!driver) return; |
| 194 | + // Federated (external) object (ADR-0015): register read metadata WITHOUT DDL |
| 195 | + // (its remote schema is owned externally). This is what an app's onEnable |
| 196 | + // calls after registering a late external driver so coercion maps + the |
| 197 | + // physical-table mapping exist for queries. See SqlDriver.registerExternalObject. |
| 198 | + if (isFederatedObject(obj)) { |
| 199 | + if (typeof (driver as any).registerExternalObject === 'function') { |
| 200 | + await (driver as any).registerExternalObject(obj); |
| 201 | + } |
| 202 | + return; |
| 203 | + } |
| 204 | + if (typeof (driver as any).syncSchema !== 'function') return; |
| 205 | + const tableName = StorageNameMapping.resolveTableName(obj); |
| 206 | + await (driver as any).syncSchema(tableName, obj); |
| 207 | +} |
| 208 | + |
| 209 | +/** The body of `ObjectQL#removePackage`; the method's docblock there is its contract. */ |
| 210 | +export function removePackage( |
| 211 | + this: Pick<CompatibilityApiHost, 'hooks' | 'removeActionsByPackage' | '_registry'>, |
| 212 | + packageId: string, |
| 213 | +): void { |
| 214 | + // Remove hooks |
| 215 | + for (const [key, handlers] of this.hooks.entries()) { |
| 216 | + const filtered = handlers.filter(h => h.packageId !== packageId); |
| 217 | + if (filtered.length !== handlers.length) { |
| 218 | + this.hooks.set(key, filtered); |
| 219 | + } |
| 220 | + } |
| 221 | + // Remove actions |
| 222 | + this.removeActionsByPackage(packageId); |
| 223 | + // Remove objects |
| 224 | + this._registry.unregisterObjectsByPackage(packageId, true); |
| 225 | +} |
0 commit comments