Skip to content

Commit 9fce8d9

Browse files
committed
refactor(objectql): move the compatibility-API bodies out of engine.ts into engine-compatibility-api.ts (#25522 cut 8)
registerObject, getConfigs, introspectDatasource, syncSchemas, syncObjectSchema and removePackage move unedited into a new module; each method keeps its name, signature, docblock and position and hands the body the members it reads through CompatibilityApiHost. The federated injected-column reader pin re-keys the two schema-sync rows to the module. Claude-Session: https://claude.ai/code/session_012CWiXJotHHDyeTL3Hea2Ad Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7111e14 commit 9fce8d9

3 files changed

Lines changed: 273 additions & 118 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
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

Comments
 (0)