@objectstack/objectql 4.0.4 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts DELETED
@@ -1,534 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectQL } from './engine.js';
4
- import { ObjectStackProtocolImplementation } from './protocol.js';
5
- import { Plugin, PluginContext } from '@objectstack/core';
6
-
7
- export type { Plugin, PluginContext };
8
-
9
- /**
10
- * Protocol extension for DB-based metadata hydration.
11
- * `loadMetaFromDb` is implemented by ObjectStackProtocolImplementation but
12
- * is NOT (yet) part of the canonical ObjectStackProtocol wire-contract in
13
- * `@objectstack/spec`, since it is a server-side bootstrap concern only.
14
- */
15
- interface ProtocolWithDbRestore {
16
- loadMetaFromDb(): Promise<{ loaded: number; errors: number }>;
17
- }
18
-
19
- /** Type guard — checks whether the service exposes `loadMetaFromDb`. */
20
- function hasLoadMetaFromDb(service: unknown): service is ProtocolWithDbRestore {
21
- return (
22
- typeof service === 'object' &&
23
- service !== null &&
24
- typeof (service as Record<string, unknown>)['loadMetaFromDb'] === 'function'
25
- );
26
- }
27
-
28
- export class ObjectQLPlugin implements Plugin {
29
- name = 'com.objectstack.engine.objectql';
30
- type = 'objectql';
31
- version = '1.0.0';
32
-
33
- private ql: ObjectQL | undefined;
34
- private hostContext?: Record<string, any>;
35
-
36
- constructor(ql?: ObjectQL, hostContext?: Record<string, any>) {
37
- if (ql) {
38
- this.ql = ql;
39
- } else {
40
- this.hostContext = hostContext;
41
- // Lazily created in init
42
- }
43
- }
44
-
45
- init = async (ctx: PluginContext) => {
46
- if (!this.ql) {
47
- // Pass kernel logger to engine to avoid creating a separate pino instance
48
- const hostCtx = { ...this.hostContext, logger: ctx.logger };
49
- this.ql = new ObjectQL(hostCtx);
50
- }
51
-
52
- // Register as provider for Core Kernel Services
53
- ctx.registerService('objectql', this.ql);
54
-
55
- ctx.registerService('data', this.ql); // ObjectQL implements IDataEngine
56
-
57
- // Register manifest service for direct app/package registration.
58
- // Plugins call ctx.getService('manifest').register(manifestData)
59
- // instead of the legacy ctx.registerService('app.<id>', manifestData) convention.
60
- const ql = this.ql;
61
- ctx.registerService('manifest', {
62
- register: (manifest: any) => {
63
- ql.registerApp(manifest);
64
- ctx.logger.debug('Manifest registered via manifest service', {
65
- id: manifest.id || manifest.name
66
- });
67
- }
68
- });
69
-
70
- ctx.logger.info('ObjectQL engine registered', {
71
- services: ['objectql', 'data', 'manifest'],
72
- });
73
-
74
- // Register Protocol Implementation
75
- const protocolShim = new ObjectStackProtocolImplementation(
76
- this.ql,
77
- () => ctx.getServices ? ctx.getServices() : new Map()
78
- );
79
-
80
- ctx.registerService('protocol', protocolShim);
81
- ctx.logger.info('Protocol service registered');
82
- }
83
-
84
- start = async (ctx: PluginContext) => {
85
- ctx.logger.info('ObjectQL engine starting...');
86
-
87
- // Sync from external metadata service (e.g. MetadataPlugin) if available
88
- try {
89
- const metadataService = ctx.getService('metadata') as any;
90
- if (metadataService && typeof metadataService.loadMany === 'function' && this.ql) {
91
- await this.loadMetadataFromService(metadataService, ctx);
92
- }
93
- } catch (e: any) {
94
- ctx.logger.debug('No external metadata service to sync from');
95
- }
96
-
97
- // Discover features from Kernel Services
98
- if (ctx.getServices && this.ql) {
99
- const services = ctx.getServices();
100
- for (const [name, service] of services.entries()) {
101
- if (name.startsWith('driver.')) {
102
- // Register Driver
103
- this.ql.registerDriver(service);
104
- ctx.logger.debug('Discovered and registered driver service', { serviceName: name });
105
- }
106
- if (name.startsWith('app.')) {
107
- // Legacy fallback: discover app.* services (DEPRECATED)
108
- ctx.logger.warn(
109
- `[DEPRECATED] Service "${name}" uses legacy app.* convention. ` +
110
- `Migrate to ctx.getService('manifest').register(data).`
111
- );
112
- this.ql.registerApp(service); // service is Manifest
113
- ctx.logger.debug('Discovered and registered app service (legacy)', { serviceName: name });
114
- }
115
- }
116
-
117
- // Bridge realtime service from kernel service registry to ObjectQL.
118
- // RealtimeServicePlugin registers as 'realtime' service during init().
119
- // This enables ObjectQL to publish data change events.
120
- try {
121
- const realtimeService = ctx.getService('realtime');
122
- if (realtimeService && typeof realtimeService === 'object' && 'publish' in realtimeService) {
123
- ctx.logger.info('[ObjectQLPlugin] Bridging realtime service to ObjectQL for event publishing');
124
- this.ql.setRealtimeService(realtimeService as any);
125
- }
126
- } catch (e: any) {
127
- ctx.logger.debug('[ObjectQLPlugin] No realtime service found — data events will not be published', {
128
- error: e.message,
129
- });
130
- }
131
- }
132
-
133
- // Initialize drivers (calls driver.connect() which sets up persistence)
134
- await this.ql?.init();
135
-
136
- // Restore persisted metadata from sys_metadata table.
137
- // This hydrates SchemaRegistry with objects/views/apps that were saved
138
- // via protocol.saveMetaItem() in a previous session, ensuring custom
139
- // schemas survive cold starts and redeployments.
140
- await this.restoreMetadataFromDb(ctx);
141
-
142
- // Sync all registered object schemas to database
143
- // This ensures tables/collections are created or updated for every
144
- // object registered by plugins (e.g., sys_user from plugin-auth).
145
- await this.syncRegisteredSchemas(ctx);
146
-
147
- // Bridge all SchemaRegistry objects to metadata service
148
- // This ensures AI tools and other IMetadataService consumers can see all objects
149
- await this.bridgeObjectsToMetadataService(ctx);
150
-
151
- // Register built-in audit hooks
152
- this.registerAuditHooks(ctx);
153
-
154
- // Register tenant isolation middleware
155
- this.registerTenantMiddleware(ctx);
156
-
157
- ctx.logger.info('ObjectQL engine started', {
158
- driversRegistered: this.ql?.['drivers']?.size || 0,
159
- objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
160
- });
161
- }
162
-
163
- /**
164
- * Register built-in audit hooks for auto-stamping created_by/updated_by
165
- * and fetching previousData for update/delete operations.
166
- */
167
- private registerAuditHooks(ctx: PluginContext) {
168
- if (!this.ql) return;
169
-
170
- // Auto-stamp created_by/updated_by on insert
171
- this.ql.registerHook('beforeInsert', async (hookCtx) => {
172
- if (hookCtx.session?.userId && hookCtx.input?.data) {
173
- const data = hookCtx.input.data as Record<string, any>;
174
- if (typeof data === 'object' && data !== null) {
175
- data.created_by = data.created_by ?? hookCtx.session.userId;
176
- data.updated_by = hookCtx.session.userId;
177
- data.created_at = data.created_at ?? new Date().toISOString();
178
- data.updated_at = new Date().toISOString();
179
- if (hookCtx.session.tenantId) {
180
- data.tenant_id = data.tenant_id ?? hookCtx.session.tenantId;
181
- }
182
- }
183
- }
184
- }, { object: '*', priority: 10 });
185
-
186
- // Auto-stamp updated_by on update
187
- this.ql.registerHook('beforeUpdate', async (hookCtx) => {
188
- if (hookCtx.session?.userId && hookCtx.input?.data) {
189
- const data = hookCtx.input.data as Record<string, any>;
190
- if (typeof data === 'object' && data !== null) {
191
- data.updated_by = hookCtx.session.userId;
192
- data.updated_at = new Date().toISOString();
193
- }
194
- }
195
- }, { object: '*', priority: 10 });
196
-
197
- // Auto-fetch previousData for update hooks
198
- this.ql.registerHook('beforeUpdate', async (hookCtx) => {
199
- if (hookCtx.input?.id && !hookCtx.previous) {
200
- try {
201
- const existing = await this.ql!.findOne(hookCtx.object, {
202
- where: { id: hookCtx.input.id }
203
- });
204
- if (existing) {
205
- hookCtx.previous = existing;
206
- }
207
- } catch (_e) {
208
- // Non-fatal: some objects may not support findOne
209
- }
210
- }
211
- }, { object: '*', priority: 5 });
212
-
213
- // Auto-fetch previousData for delete hooks
214
- this.ql.registerHook('beforeDelete', async (hookCtx) => {
215
- if (hookCtx.input?.id && !hookCtx.previous) {
216
- try {
217
- const existing = await this.ql!.findOne(hookCtx.object, {
218
- where: { id: hookCtx.input.id }
219
- });
220
- if (existing) {
221
- hookCtx.previous = existing;
222
- }
223
- } catch (_e) {
224
- // Non-fatal
225
- }
226
- }
227
- }, { object: '*', priority: 5 });
228
-
229
- ctx.logger.debug('Audit hooks registered (created_by/updated_by, previousData)');
230
- }
231
-
232
- /**
233
- * Register tenant isolation middleware that auto-injects tenant_id filter
234
- * for multi-tenant operations.
235
- */
236
- private registerTenantMiddleware(ctx: PluginContext) {
237
- if (!this.ql) return;
238
-
239
- this.ql.registerMiddleware(async (opCtx, next) => {
240
- // Only apply to operations with tenantId that are not system-level
241
- if (!opCtx.context?.tenantId || opCtx.context?.isSystem) {
242
- return next();
243
- }
244
-
245
- // Read operations: inject tenant_id filter into AST
246
- if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) {
247
- if (opCtx.ast) {
248
- const tenantFilter = { tenant_id: opCtx.context.tenantId };
249
- if (opCtx.ast.where) {
250
- opCtx.ast.where = { $and: [opCtx.ast.where, tenantFilter] };
251
- } else {
252
- opCtx.ast.where = tenantFilter;
253
- }
254
- }
255
- }
256
-
257
- await next();
258
- });
259
-
260
- ctx.logger.debug('Tenant isolation middleware registered');
261
- }
262
-
263
- /**
264
- * Synchronize all registered object schemas to the database.
265
- *
266
- * Groups objects by their responsible driver, then:
267
- * - If the driver advertises `supports.batchSchemaSync` and implements
268
- * `syncSchemasBatch()`, submits all schemas in a single call (reducing
269
- * network round-trips for remote drivers like Turso).
270
- * - Otherwise falls back to sequential `syncSchema()` per object.
271
- *
272
- * This is idempotent — drivers must tolerate repeated calls without
273
- * duplicating tables or erroring out.
274
- *
275
- * Drivers that do not implement `syncSchema` are silently skipped.
276
- */
277
- private async syncRegisteredSchemas(ctx: PluginContext) {
278
- if (!this.ql) return;
279
-
280
- const allObjects = this.ql.registry?.getAllObjects?.() ?? [];
281
- if (allObjects.length === 0) return;
282
-
283
- let synced = 0;
284
- let skipped = 0;
285
-
286
- // Group objects by driver for potential batch optimization
287
- const driverGroups = new Map<any, Array<{ obj: any; tableName: string }>>();
288
-
289
- for (const obj of allObjects) {
290
- const driver = this.ql.getDriverForObject(obj.name);
291
- if (!driver) {
292
- ctx.logger.debug('No driver available for object, skipping schema sync', {
293
- object: obj.name,
294
- });
295
- skipped++;
296
- continue;
297
- }
298
-
299
- if (typeof driver.syncSchema !== 'function') {
300
- ctx.logger.debug('Driver does not support syncSchema, skipping', {
301
- object: obj.name,
302
- driver: driver.name,
303
- });
304
- skipped++;
305
- continue;
306
- }
307
-
308
- const tableName = obj.tableName || obj.name;
309
-
310
- let group = driverGroups.get(driver);
311
- if (!group) {
312
- group = [];
313
- driverGroups.set(driver, group);
314
- }
315
- group.push({ obj, tableName });
316
- }
317
-
318
- // Process each driver group
319
- for (const [driver, entries] of driverGroups) {
320
- // Batch path: driver supports batch schema sync
321
- if (
322
- driver.supports?.batchSchemaSync &&
323
- typeof driver.syncSchemasBatch === 'function'
324
- ) {
325
- const batchPayload = entries.map((e) => ({
326
- object: e.tableName,
327
- schema: e.obj,
328
- }));
329
- try {
330
- await driver.syncSchemasBatch(batchPayload);
331
- synced += entries.length;
332
- ctx.logger.debug('Batch schema sync succeeded', {
333
- driver: driver.name,
334
- count: entries.length,
335
- });
336
- } catch (e: unknown) {
337
- ctx.logger.warn('Batch schema sync failed, falling back to sequential', {
338
- driver: driver.name,
339
- error: e instanceof Error ? e.message : String(e),
340
- });
341
- // Fallback: sequential sync for this driver's objects
342
- for (const { obj, tableName } of entries) {
343
- try {
344
- await driver.syncSchema(tableName, obj);
345
- synced++;
346
- } catch (seqErr: unknown) {
347
- ctx.logger.warn('Failed to sync schema for object', {
348
- object: obj.name,
349
- tableName,
350
- driver: driver.name,
351
- error: seqErr instanceof Error ? seqErr.message : String(seqErr),
352
- });
353
- }
354
- }
355
- }
356
- } else {
357
- // Sequential path: no batch support
358
- for (const { obj, tableName } of entries) {
359
- try {
360
- await driver.syncSchema(tableName, obj);
361
- synced++;
362
- } catch (e: unknown) {
363
- ctx.logger.warn('Failed to sync schema for object', {
364
- object: obj.name,
365
- tableName,
366
- driver: driver.name,
367
- error: e instanceof Error ? e.message : String(e),
368
- });
369
- }
370
- }
371
- }
372
- }
373
-
374
- if (synced > 0 || skipped > 0) {
375
- ctx.logger.info('Schema sync complete', { synced, skipped, total: allObjects.length });
376
- }
377
- }
378
-
379
- /**
380
- * Restore persisted metadata from the database (sys_metadata) on startup.
381
- *
382
- * Calls `protocol.loadMetaFromDb()` to bulk-load all active metadata
383
- * records (objects, views, apps, etc.) into the in-memory SchemaRegistry.
384
- * This closes the persistence loop so that user-created schemas survive
385
- * kernel cold starts and redeployments.
386
- *
387
- * Gracefully degrades when:
388
- * - The protocol service is unavailable (e.g., in-memory-only mode).
389
- * - `loadMetaFromDb` is not implemented by the protocol shim.
390
- * - The underlying driver/table does not exist yet (first-run scenario).
391
- */
392
- private async restoreMetadataFromDb(ctx: PluginContext): Promise<void> {
393
- // Phase 1: Resolve protocol service (separate from DB I/O for clearer diagnostics)
394
- let protocol: ProtocolWithDbRestore;
395
- try {
396
- const service = ctx.getService('protocol');
397
- if (!service || !hasLoadMetaFromDb(service)) {
398
- ctx.logger.debug('Protocol service does not support loadMetaFromDb, skipping DB restore');
399
- return;
400
- }
401
- protocol = service;
402
- } catch (e: unknown) {
403
- ctx.logger.debug('Protocol service unavailable, skipping DB restore', {
404
- error: e instanceof Error ? e.message : String(e),
405
- });
406
- return;
407
- }
408
-
409
- // Phase 2: DB hydration (loads into SchemaRegistry)
410
- try {
411
- const { loaded, errors } = await protocol.loadMetaFromDb();
412
-
413
- if (loaded > 0 || errors > 0) {
414
- ctx.logger.info('Metadata restored from database to SchemaRegistry', { loaded, errors });
415
- } else {
416
- ctx.logger.debug('No persisted metadata found in database');
417
- }
418
- } catch (e: unknown) {
419
- // Non-fatal: first-run or in-memory driver may not have sys_metadata yet
420
- ctx.logger.debug('DB metadata restore failed (non-fatal)', {
421
- error: e instanceof Error ? e.message : String(e),
422
- });
423
- }
424
- }
425
-
426
- /**
427
- * Bridge all SchemaRegistry objects to the metadata service.
428
- *
429
- * This ensures objects registered by plugins and loaded from sys_metadata
430
- * are visible to AI tools and other consumers that query IMetadataService.
431
- *
432
- * Runs after both restoreMetadataFromDb() and syncRegisteredSchemas() to
433
- * catch all objects in the SchemaRegistry regardless of their source.
434
- */
435
- private async bridgeObjectsToMetadataService(ctx: PluginContext): Promise<void> {
436
- try {
437
- const metadataService = ctx.getService<any>('metadata');
438
- if (!metadataService || typeof metadataService.register !== 'function') {
439
- ctx.logger.debug('Metadata service unavailable for bridging, skipping');
440
- return;
441
- }
442
-
443
- if (!this.ql?.registry) {
444
- ctx.logger.debug('SchemaRegistry unavailable for bridging, skipping');
445
- return;
446
- }
447
-
448
- const objects = this.ql.registry.getAllObjects();
449
- let bridged = 0;
450
-
451
- for (const obj of objects) {
452
- try {
453
- // Check if object is already in metadata service to avoid duplicates
454
- const existing = await metadataService.getObject(obj.name);
455
- if (!existing) {
456
- // Register object that exists in SchemaRegistry but not in metadata service
457
- await metadataService.register('object', obj.name, obj);
458
- bridged++;
459
- }
460
- } catch (e: unknown) {
461
- ctx.logger.debug('Failed to bridge object to metadata service', {
462
- object: obj.name,
463
- error: e instanceof Error ? e.message : String(e),
464
- });
465
- }
466
- }
467
-
468
- if (bridged > 0) {
469
- ctx.logger.info('Bridged objects from SchemaRegistry to metadata service', {
470
- count: bridged,
471
- total: objects.length
472
- });
473
- } else {
474
- ctx.logger.debug('No objects needed bridging (all already in metadata service)');
475
- }
476
- } catch (e: unknown) {
477
- ctx.logger.debug('Failed to bridge objects to metadata service', {
478
- error: e instanceof Error ? e.message : String(e),
479
- });
480
- }
481
- }
482
-
483
- /**
484
- * Load metadata from external metadata service into ObjectQL registry
485
- * This enables ObjectQL to use file-based or remote metadata
486
- */
487
- private async loadMetadataFromService(metadataService: any, ctx: PluginContext) {
488
- ctx.logger.info('Syncing metadata from external service into ObjectQL registry...');
489
-
490
- // Metadata types to sync
491
- const metadataTypes = ['object', 'view', 'app', 'flow', 'workflow', 'function'];
492
- let totalLoaded = 0;
493
-
494
- for (const type of metadataTypes) {
495
- try {
496
- // Check if service has loadMany method
497
- if (typeof metadataService.loadMany === 'function') {
498
- const items = await metadataService.loadMany(type);
499
-
500
- if (items && items.length > 0) {
501
- items.forEach((item: any) => {
502
- // Determine key field (usually 'name' or 'id')
503
- const keyField = item.id ? 'id' : 'name';
504
-
505
- // For objects, use the ownership-aware registration
506
- if (type === 'object' && this.ql) {
507
- // Objects are registered differently (ownership model)
508
- // Skip for now - handled by app registration
509
- return;
510
- }
511
-
512
- // Register other types in the registry
513
- if (this.ql?.registry?.registerItem) {
514
- this.ql.registry.registerItem(type, item, keyField);
515
- }
516
- });
517
-
518
- totalLoaded += items.length;
519
- ctx.logger.info(`Synced ${items.length} ${type}(s) from metadata service`);
520
- }
521
- }
522
- } catch (e: any) {
523
- // Type might not exist in metadata service - that's ok
524
- ctx.logger.debug(`No ${type} metadata found or error loading`, {
525
- error: e.message
526
- });
527
- }
528
- }
529
-
530
- if (totalLoaded > 0) {
531
- ctx.logger.info(`Metadata sync complete: ${totalLoaded} items loaded into ObjectQL registry`);
532
- }
533
- }
534
- }