-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathplugin.ts
More file actions
351 lines (303 loc) · 12.1 KB
/
plugin.ts
File metadata and controls
351 lines (303 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { ObjectQL } from './engine.js';
import { MetadataFacade } from './metadata-facade.js';
import { ObjectStackProtocolImplementation } from './protocol.js';
import { Plugin, PluginContext } from '@objectstack/core';
export type { Plugin, PluginContext };
export class ObjectQLPlugin implements Plugin {
name = 'com.objectstack.engine.objectql';
type = 'objectql';
version = '1.0.0';
private ql: ObjectQL | undefined;
private hostContext?: Record<string, any>;
constructor(ql?: ObjectQL, hostContext?: Record<string, any>) {
if (ql) {
this.ql = ql;
} else {
this.hostContext = hostContext;
// Lazily created in init
}
}
init = async (ctx: PluginContext) => {
if (!this.ql) {
// Pass kernel logger to engine to avoid creating a separate pino instance
const hostCtx = { ...this.hostContext, logger: ctx.logger };
this.ql = new ObjectQL(hostCtx);
}
// Register as provider for Core Kernel Services
ctx.registerService('objectql', this.ql);
// Register MetadataFacade as metadata service (unless external service exists)
let hasMetadata = false;
let metadataProvider = 'objectql';
try {
if (ctx.getService('metadata')) {
hasMetadata = true;
metadataProvider = 'external';
}
} catch (e: any) {
// Ignore errors during check (e.g. "Service is async")
}
if (!hasMetadata) {
try {
const metadataFacade = new MetadataFacade();
ctx.registerService('metadata', metadataFacade);
ctx.logger.info('MetadataFacade registered as metadata service', {
mode: 'in-memory',
features: ['registry', 'fast-lookup']
});
} catch (e: any) {
// Ignore if already registered (race condition or async mis-detection)
if (!e.message?.includes('already registered')) {
throw e;
}
}
} else {
ctx.logger.info('External metadata service detected', {
provider: metadataProvider,
mode: 'will-sync-in-start-phase'
});
}
ctx.registerService('data', this.ql); // ObjectQL implements IDataEngine
ctx.logger.info('ObjectQL engine registered', {
services: ['objectql', 'data'],
metadataProvider: metadataProvider
});
// Register Protocol Implementation
const protocolShim = new ObjectStackProtocolImplementation(
this.ql,
() => ctx.getServices ? ctx.getServices() : new Map()
);
ctx.registerService('protocol', protocolShim);
ctx.logger.info('Protocol service registered');
}
start = async (ctx: PluginContext) => {
ctx.logger.info('ObjectQL engine starting...');
// Check if we should load from external metadata service
try {
const metadataService = ctx.getService('metadata') as any;
// Only sync if metadata service is external (not our own MetadataFacade)
if (metadataService && !(metadataService instanceof MetadataFacade) && this.ql) {
await this.loadMetadataFromService(metadataService, ctx);
}
} catch (e: any) {
// No external metadata service or error accessing it
ctx.logger.debug('No external metadata service to sync from');
}
// Discover features from Kernel Services
if (ctx.getServices && this.ql) {
const services = ctx.getServices();
for (const [name, service] of services.entries()) {
if (name.startsWith('driver.')) {
// Register Driver
this.ql.registerDriver(service);
ctx.logger.debug('Discovered and registered driver service', { serviceName: name });
}
if (name.startsWith('app.')) {
// Register App
this.ql.registerApp(service); // service is Manifest
ctx.logger.debug('Discovered and registered app service', { serviceName: name });
}
}
}
// Initialize drivers (calls driver.connect() which sets up persistence)
await this.ql?.init();
// Sync all registered object schemas to database
// This ensures tables/collections are created or updated for every
// object registered by plugins (e.g., sys_user from plugin-auth).
await this.syncRegisteredSchemas(ctx);
// Register built-in audit hooks
this.registerAuditHooks(ctx);
// Register tenant isolation middleware
this.registerTenantMiddleware(ctx);
ctx.logger.info('ObjectQL engine started', {
driversRegistered: this.ql?.['drivers']?.size || 0,
objectsRegistered: this.ql?.registry?.getAllObjects?.()?.length || 0
});
}
/**
* Register built-in audit hooks for auto-stamping created_by/updated_by
* and fetching previousData for update/delete operations.
*/
private registerAuditHooks(ctx: PluginContext) {
if (!this.ql) return;
// Auto-stamp created_by/updated_by on insert
this.ql.registerHook('beforeInsert', async (hookCtx) => {
if (hookCtx.session?.userId && hookCtx.input?.data) {
const data = hookCtx.input.data as Record<string, any>;
if (typeof data === 'object' && data !== null) {
data.created_by = data.created_by ?? hookCtx.session.userId;
data.updated_by = hookCtx.session.userId;
data.created_at = data.created_at ?? new Date().toISOString();
data.updated_at = new Date().toISOString();
if (hookCtx.session.tenantId) {
data.tenant_id = data.tenant_id ?? hookCtx.session.tenantId;
}
}
}
}, { object: '*', priority: 10 });
// Auto-stamp updated_by on update
this.ql.registerHook('beforeUpdate', async (hookCtx) => {
if (hookCtx.session?.userId && hookCtx.input?.data) {
const data = hookCtx.input.data as Record<string, any>;
if (typeof data === 'object' && data !== null) {
data.updated_by = hookCtx.session.userId;
data.updated_at = new Date().toISOString();
}
}
}, { object: '*', priority: 10 });
// Auto-fetch previousData for update hooks
this.ql.registerHook('beforeUpdate', async (hookCtx) => {
if (hookCtx.input?.id && !hookCtx.previous) {
try {
const existing = await this.ql!.findOne(hookCtx.object, {
filter: { id: hookCtx.input.id }
});
if (existing) {
hookCtx.previous = existing;
}
} catch (_e) {
// Non-fatal: some objects may not support findOne
}
}
}, { object: '*', priority: 5 });
// Auto-fetch previousData for delete hooks
this.ql.registerHook('beforeDelete', async (hookCtx) => {
if (hookCtx.input?.id && !hookCtx.previous) {
try {
const existing = await this.ql!.findOne(hookCtx.object, {
filter: { id: hookCtx.input.id }
});
if (existing) {
hookCtx.previous = existing;
}
} catch (_e) {
// Non-fatal
}
}
}, { object: '*', priority: 5 });
ctx.logger.debug('Audit hooks registered (created_by/updated_by, previousData)');
}
/**
* Register tenant isolation middleware that auto-injects tenant_id filter
* for multi-tenant operations.
*/
private registerTenantMiddleware(ctx: PluginContext) {
if (!this.ql) return;
this.ql.registerMiddleware(async (opCtx, next) => {
// Only apply to operations with tenantId that are not system-level
if (!opCtx.context?.tenantId || opCtx.context?.isSystem) {
return next();
}
// Read operations: inject tenant_id filter into AST
if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) {
if (opCtx.ast) {
const tenantFilter = { tenant_id: opCtx.context.tenantId };
if (opCtx.ast.where) {
opCtx.ast.where = { $and: [opCtx.ast.where, tenantFilter] };
} else {
opCtx.ast.where = tenantFilter;
}
}
}
await next();
});
ctx.logger.debug('Tenant isolation middleware registered');
}
/**
* Synchronize all registered object schemas to the database.
*
* Iterates every object in the SchemaRegistry and calls the
* responsible driver's `syncSchema()` for each one. This is
* idempotent — drivers must tolerate repeated calls without
* duplicating tables or erroring out.
*
* Drivers that do not implement `syncSchema` are silently skipped.
*/
private async syncRegisteredSchemas(ctx: PluginContext) {
if (!this.ql) return;
const allObjects = this.ql.registry?.getAllObjects?.() ?? [];
if (allObjects.length === 0) return;
let synced = 0;
let skipped = 0;
for (const obj of allObjects) {
const driver = this.ql.getDriverForObject(obj.name);
if (!driver) {
ctx.logger.debug('No driver available for object, skipping schema sync', {
object: obj.name,
});
skipped++;
continue;
}
if (typeof driver.syncSchema !== 'function') {
ctx.logger.debug('Driver does not support syncSchema, skipping', {
object: obj.name,
driver: driver.name,
});
skipped++;
continue;
}
// Use the physical table name (e.g., 'sys_user') for DDL operations
// instead of the FQN (e.g., 'sys__user'). ObjectSchema.create()
// auto-derives tableName as {namespace}_{name}.
const tableName = obj.tableName || obj.name;
try {
await driver.syncSchema(tableName, obj);
synced++;
} catch (e: unknown) {
ctx.logger.warn('Failed to sync schema for object', {
object: obj.name,
tableName,
driver: driver.name,
error: e instanceof Error ? e.message : String(e),
});
}
}
if (synced > 0 || skipped > 0) {
ctx.logger.info('Schema sync complete', { synced, skipped, total: allObjects.length });
}
}
/**
* Load metadata from external metadata service into ObjectQL registry
* This enables ObjectQL to use file-based or remote metadata
*/
private async loadMetadataFromService(metadataService: any, ctx: PluginContext) {
ctx.logger.info('Syncing metadata from external service into ObjectQL registry...');
// Metadata types to sync
const metadataTypes = ['object', 'view', 'app', 'flow', 'workflow', 'function'];
let totalLoaded = 0;
for (const type of metadataTypes) {
try {
// Check if service has loadMany method
if (typeof metadataService.loadMany === 'function') {
const items = await metadataService.loadMany(type);
if (items && items.length > 0) {
items.forEach((item: any) => {
// Determine key field (usually 'name' or 'id')
const keyField = item.id ? 'id' : 'name';
// For objects, use the ownership-aware registration
if (type === 'object' && this.ql) {
// Objects are registered differently (ownership model)
// Skip for now - handled by app registration
return;
}
// Register other types in the registry
if (this.ql?.registry?.registerItem) {
this.ql.registry.registerItem(type, item, keyField);
}
});
totalLoaded += items.length;
ctx.logger.info(`Synced ${items.length} ${type}(s) from metadata service`);
}
}
} catch (e: any) {
// Type might not exist in metadata service - that's ok
ctx.logger.debug(`No ${type} metadata found or error loading`, {
error: e.message
});
}
}
if (totalLoaded > 0) {
ctx.logger.info(`Metadata sync complete: ${totalLoaded} items loaded into ObjectQL registry`);
}
}
}