@objectstack/plugin-dev 2.0.6

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.
@@ -0,0 +1,779 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { Plugin, PluginContext } from '@objectstack/core';
4
+
5
+ /**
6
+ * All 17 core kernel service names as defined in CoreServiceName.
7
+ * @see packages/spec/src/system/core-services.zod.ts
8
+ */
9
+ const CORE_SERVICE_NAMES = [
10
+ 'metadata', 'data', 'auth',
11
+ 'file-storage', 'search', 'cache', 'queue',
12
+ 'automation', 'graphql', 'analytics', 'realtime',
13
+ 'job', 'notification', 'ai', 'i18n', 'ui', 'workflow',
14
+ ] as const;
15
+
16
+ /**
17
+ * Security sub-services registered by the SecurityPlugin.
18
+ */
19
+ const SECURITY_SERVICE_NAMES = [
20
+ 'security.permissions', 'security.rls', 'security.fieldMasker',
21
+ ] as const;
22
+
23
+ /**
24
+ * Contract-compliant dev stub implementations.
25
+ *
26
+ * Each stub implements the interface defined in `packages/spec/src/contracts/`
27
+ * (e.g. ICacheService, IQueueService, IAutomationService, …) so that
28
+ * downstream code calling these services in dev mode receives the correct
29
+ * return types — not just `undefined`.
30
+ *
31
+ * Where an interface method is optional (marked with `?`), the stub only
32
+ * implements the required methods plus any optional ones that have
33
+ * a trivially useful implementation.
34
+ */
35
+
36
+ /** ICacheService — in-memory Map-backed stub */
37
+ function createCacheStub() {
38
+ const store = new Map<string, { value: unknown; expires?: number }>();
39
+ let hits = 0;
40
+ let misses = 0;
41
+ return {
42
+ _dev: true, _serviceName: 'cache',
43
+ async get<T = unknown>(key: string): Promise<T | undefined> {
44
+ const entry = store.get(key);
45
+ if (!entry || (entry.expires && Date.now() > entry.expires)) {
46
+ store.delete(key);
47
+ misses++;
48
+ return undefined;
49
+ }
50
+ hits++;
51
+ return entry.value as T;
52
+ },
53
+ async set<T = unknown>(key: string, value: T, ttl?: number): Promise<void> {
54
+ store.set(key, { value, expires: ttl ? Date.now() + ttl * 1000 : undefined });
55
+ },
56
+ async delete(key: string): Promise<boolean> { return store.delete(key); },
57
+ async has(key: string): Promise<boolean> { return store.has(key); },
58
+ async clear(): Promise<void> { store.clear(); },
59
+ async stats() { return { hits, misses, keyCount: store.size }; },
60
+ };
61
+ }
62
+
63
+ /** IQueueService — in-memory publish/subscribe stub */
64
+ function createQueueStub() {
65
+ const handlers = new Map<string, Function[]>();
66
+ let msgId = 0;
67
+ return {
68
+ _dev: true, _serviceName: 'queue',
69
+ async publish<T = unknown>(queue: string, data: T): Promise<string> {
70
+ const id = `dev-msg-${++msgId}`;
71
+ const fns = handlers.get(queue) ?? [];
72
+ for (const fn of fns) fn({ id, data, attempts: 1, timestamp: Date.now() });
73
+ return id;
74
+ },
75
+ async subscribe(queue: string, handler: (msg: any) => Promise<void>): Promise<void> {
76
+ handlers.set(queue, [...(handlers.get(queue) ?? []), handler]);
77
+ },
78
+ async unsubscribe(queue: string): Promise<void> { handlers.delete(queue); },
79
+ async getQueueSize(): Promise<number> { return 0; },
80
+ async purge(queue: string): Promise<void> { handlers.delete(queue); },
81
+ };
82
+ }
83
+
84
+ /** IJobService — no-op job scheduler stub */
85
+ function createJobStub() {
86
+ const jobs = new Map<string, any>();
87
+ return {
88
+ _dev: true, _serviceName: 'job',
89
+ async schedule(name: string, schedule: any, handler: any): Promise<void> { jobs.set(name, { schedule, handler }); },
90
+ async cancel(name: string): Promise<void> { jobs.delete(name); },
91
+ async trigger(name: string, data?: unknown): Promise<void> {
92
+ const job = jobs.get(name);
93
+ if (job?.handler) await job.handler({ jobId: name, data });
94
+ },
95
+ async getExecutions(): Promise<any[]> { return []; },
96
+ async listJobs(): Promise<string[]> { return [...jobs.keys()]; },
97
+ };
98
+ }
99
+
100
+ /** IStorageService — in-memory file storage stub */
101
+ function createStorageStub() {
102
+ const files = new Map<string, { data: Buffer; meta: any }>();
103
+ return {
104
+ _dev: true, _serviceName: 'file-storage',
105
+ async upload(key: string, data: any, options?: any): Promise<void> {
106
+ files.set(key, { data: Buffer.from(data), meta: { contentType: options?.contentType, metadata: options?.metadata } });
107
+ },
108
+ async download(key: string): Promise<Buffer> { return files.get(key)?.data ?? Buffer.alloc(0); },
109
+ async delete(key: string): Promise<void> { files.delete(key); },
110
+ async exists(key: string): Promise<boolean> { return files.has(key); },
111
+ async getInfo(key: string) {
112
+ const f = files.get(key);
113
+ return { key, size: f?.data?.length ?? 0, contentType: f?.meta?.contentType, lastModified: new Date(), metadata: f?.meta?.metadata };
114
+ },
115
+ async list(prefix: string) {
116
+ return [...files.entries()].filter(([k]) => k.startsWith(prefix)).map(([key, f]) =>
117
+ ({ key, size: f.data.length, contentType: f.meta?.contentType, lastModified: new Date() }));
118
+ },
119
+ };
120
+ }
121
+
122
+ /** ISearchService — in-memory full-text search stub */
123
+ function createSearchStub() {
124
+ const indexes = new Map<string, Map<string, Record<string, unknown>>>();
125
+ return {
126
+ _dev: true, _serviceName: 'search',
127
+ async index(object: string, id: string, document: Record<string, unknown>): Promise<void> {
128
+ if (!indexes.has(object)) indexes.set(object, new Map());
129
+ indexes.get(object)!.set(id, document);
130
+ },
131
+ async remove(object: string, id: string): Promise<void> { indexes.get(object)?.delete(id); },
132
+ async search(object: string, query: string) {
133
+ const docs = indexes.get(object) ?? new Map();
134
+ const q = query.toLowerCase();
135
+ const hits = [...docs.entries()]
136
+ .filter(([, doc]) => JSON.stringify(doc).toLowerCase().includes(q))
137
+ .map(([id, doc]) => ({ id, score: 1, document: doc }));
138
+ return { hits, totalHits: hits.length, processingTimeMs: 0 };
139
+ },
140
+ async bulkIndex(object: string, documents: Array<{ id: string; document: Record<string, unknown> }>): Promise<void> {
141
+ if (!indexes.has(object)) indexes.set(object, new Map());
142
+ for (const d of documents) {
143
+ indexes.get(object)!.set(d.id, d.document);
144
+ }
145
+ },
146
+ async deleteIndex(object: string): Promise<void> { indexes.delete(object); },
147
+ };
148
+ }
149
+
150
+ /** IAutomationService — no-op flow execution stub */
151
+ function createAutomationStub() {
152
+ const flows = new Map<string, unknown>();
153
+ return {
154
+ _dev: true, _serviceName: 'automation',
155
+ async execute(_flowName: string) { return { success: true, output: undefined, durationMs: 0 }; },
156
+ async listFlows(): Promise<string[]> { return [...flows.keys()]; },
157
+ registerFlow(name: string, definition: unknown) { flows.set(name, definition); },
158
+ unregisterFlow(name: string) { flows.delete(name); },
159
+ };
160
+ }
161
+
162
+ /** IGraphQLService — dev stub returning empty data */
163
+ function createGraphQLStub() {
164
+ return {
165
+ _dev: true, _serviceName: 'graphql',
166
+ async execute() { return { data: null, errors: [{ message: 'GraphQL not available in dev stub mode' }] }; },
167
+ getSchema() { return 'type Query { _dev: Boolean }'; },
168
+ };
169
+ }
170
+
171
+ /** IAnalyticsService — dev stub returning empty results */
172
+ function createAnalyticsStub() {
173
+ return {
174
+ _dev: true, _serviceName: 'analytics',
175
+ async query() { return { rows: [], fields: [] }; },
176
+ async getMeta() { return []; },
177
+ async generateSql() { return { sql: '', params: [] }; },
178
+ };
179
+ }
180
+
181
+ /** IRealtimeService — in-memory pub/sub stub */
182
+ function createRealtimeStub() {
183
+ const subs = new Map<string, Function>();
184
+ let subId = 0;
185
+ return {
186
+ _dev: true, _serviceName: 'realtime',
187
+ async publish(event: any): Promise<void> { for (const fn of subs.values()) fn(event); },
188
+ async subscribe(_channel: string, handler: Function): Promise<string> {
189
+ const id = `dev-sub-${++subId}`; subs.set(id, handler); return id;
190
+ },
191
+ async unsubscribe(subscriptionId: string): Promise<void> { subs.delete(subscriptionId); },
192
+ };
193
+ }
194
+
195
+ /** INotificationService — in-memory log stub */
196
+ function createNotificationStub() {
197
+ const sent: any[] = [];
198
+ return {
199
+ _dev: true, _serviceName: 'notification',
200
+ async send(message: any) { sent.push(message); return { success: true, messageId: `dev-notif-${sent.length}` }; },
201
+ async sendBatch(messages: any[]) { return messages.map(m => { sent.push(m); return { success: true, messageId: `dev-notif-${sent.length}` }; }); },
202
+ getChannels() { return ['email', 'in-app'] as const; },
203
+ };
204
+ }
205
+
206
+ /** IAIService — dev stub returning placeholder responses */
207
+ function createAIStub() {
208
+ return {
209
+ _dev: true, _serviceName: 'ai',
210
+ async chat() { return { content: '[dev-stub] AI not available in development mode', model: 'dev-stub' }; },
211
+ async complete() { return { content: '[dev-stub] AI not available in development mode', model: 'dev-stub' }; },
212
+ async embed() { return [[0]]; },
213
+ async listModels() { return ['dev-stub']; },
214
+ };
215
+ }
216
+
217
+ /** II18nService — in-memory translation stub */
218
+ function createI18nStub() {
219
+ const translations = new Map<string, Record<string, unknown>>();
220
+ let defaultLocale = 'en';
221
+ return {
222
+ _dev: true, _serviceName: 'i18n',
223
+ t(key: string, locale: string, params?: Record<string, unknown>): string {
224
+ const t = translations.get(locale);
225
+ const val = t?.[key];
226
+ if (typeof val === 'string') {
227
+ return params ? val.replace(/\{\{(\w+)\}\}/g, (_, k) => String(params[k] ?? `{{${k}}}`)) : val;
228
+ }
229
+ return key;
230
+ },
231
+ getTranslations(locale: string): Record<string, unknown> { return translations.get(locale) ?? {}; },
232
+ loadTranslations(locale: string, data: Record<string, unknown>) { translations.set(locale, { ...translations.get(locale), ...data }); },
233
+ getLocales() { return [...translations.keys()]; },
234
+ getDefaultLocale() { return defaultLocale; },
235
+ setDefaultLocale(locale: string) { defaultLocale = locale; },
236
+ };
237
+ }
238
+
239
+ /** IUIService — in-memory UI metadata stub */
240
+ function createUIStub() {
241
+ const views = new Map<string, any>();
242
+ const dashboards = new Map<string, any>();
243
+ return {
244
+ _dev: true, _serviceName: 'ui',
245
+ getView(name: string) { return views.get(name); },
246
+ listViews(object?: string) {
247
+ const all = [...views.values()];
248
+ return object ? all.filter(v => v.object === object) : all;
249
+ },
250
+ getDashboard(name: string) { return dashboards.get(name); },
251
+ listDashboards() { return [...dashboards.values()]; },
252
+ registerView(name: string, definition: unknown) { views.set(name, definition); },
253
+ registerDashboard(name: string, definition: unknown) { dashboards.set(name, definition); },
254
+ };
255
+ }
256
+
257
+ /** IWorkflowService — in-memory workflow state stub */
258
+ function createWorkflowStub() {
259
+ const states = new Map<string, string>(); // recordKey → currentState
260
+ const key = (obj: string, id: string) => `${obj}:${id}`;
261
+ return {
262
+ _dev: true, _serviceName: 'workflow',
263
+ async transition(t: any) {
264
+ states.set(key(t.object, t.recordId), t.targetState);
265
+ return { success: true, currentState: t.targetState };
266
+ },
267
+ async getStatus(object: string, recordId: string) {
268
+ return { recordId, object, currentState: states.get(key(object, recordId)) ?? 'draft', availableTransitions: [] };
269
+ },
270
+ async getHistory() { return []; },
271
+ };
272
+ }
273
+
274
+ /** IMetadataService — in-memory metadata registry stub (fallback) */
275
+ function createMetadataStub() {
276
+ const store = new Map<string, Map<string, unknown>>(); // type → (name → def)
277
+ return {
278
+ _dev: true, _serviceName: 'metadata',
279
+ register(type: string, definition: any) {
280
+ if (!store.has(type)) store.set(type, new Map());
281
+ store.get(type)!.set(definition.name ?? '', definition);
282
+ },
283
+ get(type: string, name: string) { return store.get(type)?.get(name); },
284
+ list(type: string) { return [...(store.get(type)?.values() ?? [])]; },
285
+ unregister(type: string, name: string) { store.get(type)?.delete(name); },
286
+ getObject(name: string) { return store.get('object')?.get(name); },
287
+ listObjects() { return [...(store.get('object')?.values() ?? [])]; },
288
+ unregisterPackage() {},
289
+ };
290
+ }
291
+
292
+ /** IAuthService — dev auth stub returning success for all */
293
+ function createAuthStub() {
294
+ return {
295
+ _dev: true, _serviceName: 'auth',
296
+ async handleRequest() { return new Response(JSON.stringify({ success: true }), { status: 200 }); },
297
+ async verify() { return { success: true, user: { id: 'dev-admin', email: 'admin@dev.local', name: 'Admin', roles: ['admin'] } }; },
298
+ async logout() {},
299
+ async getCurrentUser() { return { id: 'dev-admin', email: 'admin@dev.local', name: 'Admin', roles: ['admin'] }; },
300
+ };
301
+ }
302
+
303
+ /** IDataEngine — minimal no-op data stub (fallback) */
304
+ function createDataStub() {
305
+ return {
306
+ _dev: true, _serviceName: 'data',
307
+ async find() { return []; },
308
+ async findOne() { return undefined; },
309
+ async insert(_obj: string, params: any) { return { id: `dev-${Date.now()}`, ...params?.data }; },
310
+ async update(_obj: string, _id: string, params: any) { return params?.data ?? {}; },
311
+ async delete() { return true; },
312
+ async count() { return 0; },
313
+ async aggregate() { return []; },
314
+ };
315
+ }
316
+
317
+ /** Security sub-service stubs (PermissionEvaluator, RLSCompiler, FieldMasker) */
318
+ function createSecurityPermissionsStub() {
319
+ return {
320
+ _dev: true, _serviceName: 'security.permissions',
321
+ resolvePermissionSets() { return []; },
322
+ checkObjectPermission() { return true; },
323
+ getFieldPermissions() { return {}; },
324
+ };
325
+ }
326
+ function createSecurityRLSStub() {
327
+ return {
328
+ _dev: true, _serviceName: 'security.rls',
329
+ compileFilter() { return null; },
330
+ getApplicablePolicies() { return []; },
331
+ };
332
+ }
333
+ function createSecurityFieldMaskerStub() {
334
+ return {
335
+ _dev: true, _serviceName: 'security.fieldMasker',
336
+ maskResults(results: any) { return results; },
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Map of service names → contract-compliant stub factory functions.
342
+ * Each factory creates a new instance implementing the protocol interface
343
+ * from `packages/spec/src/contracts/`.
344
+ */
345
+ const DEV_STUB_FACTORIES: Record<string, () => Record<string, any>> = {
346
+ 'cache': createCacheStub,
347
+ 'queue': createQueueStub,
348
+ 'job': createJobStub,
349
+ 'file-storage': createStorageStub,
350
+ 'search': createSearchStub,
351
+ 'automation': createAutomationStub,
352
+ 'graphql': createGraphQLStub,
353
+ 'analytics': createAnalyticsStub,
354
+ 'realtime': createRealtimeStub,
355
+ 'notification': createNotificationStub,
356
+ 'ai': createAIStub,
357
+ 'i18n': createI18nStub,
358
+ 'ui': createUIStub,
359
+ 'workflow': createWorkflowStub,
360
+ 'metadata': createMetadataStub,
361
+ 'data': createDataStub,
362
+ 'auth': createAuthStub,
363
+ // Security sub-services
364
+ 'security.permissions': createSecurityPermissionsStub,
365
+ 'security.rls': createSecurityRLSStub,
366
+ 'security.fieldMasker': createSecurityFieldMaskerStub,
367
+ };
368
+
369
+ /**
370
+ * Dev Plugin Options
371
+ *
372
+ * Configuration for the development-mode plugin.
373
+ * All options have sensible defaults — zero-config works out of the box.
374
+ */
375
+ export interface DevPluginOptions {
376
+ /**
377
+ * Port for the HTTP server.
378
+ * @default 3000
379
+ */
380
+ port?: number;
381
+
382
+ /**
383
+ * Whether to seed a default admin user for development.
384
+ * Creates `admin@dev.local` / `admin` so devs can skip login.
385
+ * @default true
386
+ */
387
+ seedAdminUser?: boolean;
388
+
389
+ /**
390
+ * Auth secret for development sessions.
391
+ * @default 'objectstack-dev-secret-DO-NOT-USE-IN-PRODUCTION!!'
392
+ */
393
+ authSecret?: string;
394
+
395
+ /**
396
+ * Auth base URL.
397
+ * @default 'http://localhost:{port}'
398
+ */
399
+ authBaseUrl?: string;
400
+
401
+ /**
402
+ * Whether to enable verbose logging.
403
+ * @default true
404
+ */
405
+ verbose?: boolean;
406
+
407
+ /**
408
+ * Override which services to enable. By default all core services are enabled.
409
+ * Set a service name to `false` to skip it.
410
+ *
411
+ * Available services: 'objectql', 'driver', 'auth', 'server', 'rest',
412
+ * 'dispatcher', 'security', plus any of the 17 CoreServiceName values
413
+ * (e.g. 'cache', 'queue', 'job', 'ui', 'automation', 'workflow', …).
414
+ */
415
+ services?: Partial<Record<string, boolean>>;
416
+
417
+ /**
418
+ * Additional plugins to load alongside the auto-configured ones.
419
+ * Useful for adding custom project plugins while still getting the dev defaults.
420
+ */
421
+ extraPlugins?: Plugin[];
422
+
423
+ /**
424
+ * Stack definition to load as a project.
425
+ * When provided, the DevPlugin wraps it in an AppPlugin so that all
426
+ * metadata (objects, views, apps, dashboards, etc.) is registered with
427
+ * the kernel and exposed through the REST/metadata APIs.
428
+ *
429
+ * This is what makes `new DevPlugin({ stack: config })` equivalent to
430
+ * a full `os serve --dev` environment: views can be read, modified, and
431
+ * saved through the API.
432
+ *
433
+ * @example
434
+ * ```ts
435
+ * import config from './objectstack.config';
436
+ * plugins: [new DevPlugin({ stack: config })]
437
+ * ```
438
+ */
439
+ stack?: Record<string, any>;
440
+ }
441
+
442
+ /**
443
+ * Development Mode Plugin for ObjectStack
444
+ *
445
+ * A convenience plugin that auto-configures the **entire** platform stack
446
+ * for local development, simulating **all 17+ kernel services** so developers
447
+ * can work in a full-featured API environment without external dependencies.
448
+ *
449
+ * Instead of manually wiring:
450
+ *
451
+ * ```ts
452
+ * plugins: [
453
+ * new ObjectQLPlugin(),
454
+ * new DriverPlugin(new InMemoryDriver()),
455
+ * new AuthPlugin({ secret: '...', baseUrl: '...' }),
456
+ * new HonoServerPlugin({ port: 3000 }),
457
+ * createRestApiPlugin(),
458
+ * createDispatcherPlugin(),
459
+ * new SecurityPlugin(),
460
+ * new AppPlugin(config),
461
+ * ]
462
+ * ```
463
+ *
464
+ * You can simply use:
465
+ *
466
+ * ```ts
467
+ * plugins: [new DevPlugin()]
468
+ * ```
469
+ *
470
+ * ## Core services (real implementations)
471
+ *
472
+ * | Service | Package | Description |
473
+ * |--------------|-----------------------------------|-------------------------------------------|
474
+ * | ObjectQL | `@objectstack/objectql` | Data engine (query, CRUD, hooks) |
475
+ * | Driver | `@objectstack/driver-memory` | In-memory database (no DB install) |
476
+ * | Auth | `@objectstack/plugin-auth` | Authentication with dev credentials |
477
+ * | Security | `@objectstack/plugin-security` | RBAC, RLS, field-level masking |
478
+ * | HTTP Server | `@objectstack/plugin-hono-server` | HTTP server on configured port |
479
+ * | REST API | `@objectstack/rest` | Auto-generated CRUD + metadata endpoints |
480
+ * | Dispatcher | `@objectstack/runtime` | Auth, GraphQL, analytics, packages, etc. |
481
+ * | App/Metadata | `@objectstack/runtime` | Project metadata (objects, views, apps) |
482
+ *
483
+ * ## Stub services (contract-compliant in-memory implementations)
484
+ *
485
+ * Any core service not provided by a real plugin is automatically registered
486
+ * as a contract-compliant dev stub that implements the interface from
487
+ * `packages/spec/src/contracts/`. Each stub returns correct types:
488
+ *
489
+ * `cache` (Map-backed), `queue` (in-memory pub/sub), `job` (no-op scheduler),
490
+ * `file-storage` (Map-backed), `search` (in-memory text search),
491
+ * `automation` (no-op flows), `graphql` (placeholder), `analytics` (empty results),
492
+ * `realtime` (in-memory pub/sub), `notification` (log), `ai` (placeholder),
493
+ * `i18n` (Map-backed translations), `ui` (Map-backed views/dashboards),
494
+ * `workflow` (Map-backed state machine)
495
+ *
496
+ * All services can be individually disabled via `options.services`.
497
+ * Peer packages are loaded via dynamic import and silently skipped if missing.
498
+ */
499
+ export class DevPlugin implements Plugin {
500
+ name = 'com.objectstack.plugin.dev';
501
+ type = 'standard';
502
+ version = '1.0.0';
503
+
504
+ private options: Required<
505
+ Pick<DevPluginOptions, 'port' | 'seedAdminUser' | 'authSecret' | 'verbose'>
506
+ > & DevPluginOptions;
507
+
508
+ private childPlugins: Plugin[] = [];
509
+
510
+ constructor(options: DevPluginOptions = {}) {
511
+ this.options = {
512
+ port: 3000,
513
+ seedAdminUser: true,
514
+ authSecret: 'objectstack-dev-secret-DO-NOT-USE-IN-PRODUCTION!!',
515
+ verbose: true,
516
+ ...options,
517
+ authBaseUrl: options.authBaseUrl ?? `http://localhost:${options.port ?? 3000}`,
518
+ };
519
+ }
520
+
521
+ /**
522
+ * Init Phase
523
+ *
524
+ * Dynamically imports and instantiates all core plugins.
525
+ * Uses dynamic imports so that peer dependencies remain optional —
526
+ * if a package isn't installed the service is silently skipped.
527
+ */
528
+ async init(ctx: PluginContext): Promise<void> {
529
+ ctx.logger.info('🚀 DevPlugin initializing — auto-configuring all services for development');
530
+
531
+ const enabled = (name: string) => this.options.services?.[name] !== false;
532
+
533
+ // 1. ObjectQL Engine (data layer + metadata service)
534
+ if (enabled('objectql')) {
535
+ try {
536
+ const { ObjectQLPlugin } = await import('@objectstack/objectql');
537
+ const qlPlugin = new ObjectQLPlugin();
538
+ this.childPlugins.push(qlPlugin);
539
+ ctx.logger.info(' ✔ ObjectQL engine enabled (data + metadata)');
540
+ } catch {
541
+ ctx.logger.warn(' ✘ @objectstack/objectql not installed — skipping data engine');
542
+ }
543
+ }
544
+
545
+ // 2. In-Memory Driver
546
+ if (enabled('driver')) {
547
+ try {
548
+ const { DriverPlugin } = await import('@objectstack/runtime') as any;
549
+ const { InMemoryDriver } = await import('@objectstack/driver-memory') as any;
550
+ const driver = new InMemoryDriver();
551
+ const driverPlugin = new DriverPlugin(driver, 'memory');
552
+ this.childPlugins.push(driverPlugin);
553
+ ctx.logger.info(' ✔ InMemoryDriver enabled');
554
+ } catch {
555
+ ctx.logger.warn(' ✘ @objectstack/runtime or @objectstack/driver-memory not installed — skipping driver');
556
+ }
557
+ }
558
+
559
+ // 3. App Plugin — registers project metadata (objects, views, apps, dashboards, etc.)
560
+ // This is the key piece that enables full API development:
561
+ // once metadata is registered, REST endpoints can read/write views, etc.
562
+ if (this.options.stack) {
563
+ try {
564
+ const { AppPlugin } = await import('@objectstack/runtime') as any;
565
+ const appPlugin = new AppPlugin(this.options.stack);
566
+ this.childPlugins.push(appPlugin);
567
+ ctx.logger.info(' ✔ App metadata loaded from stack definition');
568
+ } catch {
569
+ ctx.logger.warn(' ✘ @objectstack/runtime not installed — skipping app metadata');
570
+ }
571
+ }
572
+
573
+ // 4. Auth Plugin
574
+ if (enabled('auth')) {
575
+ try {
576
+ const { AuthPlugin } = await import('@objectstack/plugin-auth') as any;
577
+ const authPlugin = new AuthPlugin({
578
+ secret: this.options.authSecret,
579
+ baseUrl: this.options.authBaseUrl,
580
+ });
581
+ this.childPlugins.push(authPlugin);
582
+ ctx.logger.info(' ✔ Auth plugin enabled (dev credentials)');
583
+ } catch {
584
+ ctx.logger.warn(' ✘ @objectstack/plugin-auth not installed — skipping auth');
585
+ }
586
+ }
587
+
588
+ // 5. Security Plugin (RBAC, RLS, field-level masking)
589
+ if (enabled('security')) {
590
+ try {
591
+ const { SecurityPlugin } = await import('@objectstack/plugin-security') as any;
592
+ const securityPlugin = new SecurityPlugin();
593
+ this.childPlugins.push(securityPlugin);
594
+ ctx.logger.info(' ✔ Security plugin enabled (RBAC, RLS, field masking)');
595
+ } catch {
596
+ ctx.logger.debug(' ℹ @objectstack/plugin-security not installed — skipping security');
597
+ }
598
+ }
599
+
600
+ // 6. Hono HTTP Server
601
+ if (enabled('server')) {
602
+ try {
603
+ const { HonoServerPlugin } = await import('@objectstack/plugin-hono-server') as any;
604
+ const serverPlugin = new HonoServerPlugin({
605
+ port: this.options.port,
606
+ });
607
+ this.childPlugins.push(serverPlugin);
608
+ ctx.logger.info(` ✔ Hono HTTP server enabled on port ${this.options.port}`);
609
+ } catch {
610
+ ctx.logger.warn(' ✘ @objectstack/plugin-hono-server not installed — skipping HTTP server');
611
+ }
612
+ }
613
+
614
+ // 7. REST API endpoints (CRUD + metadata read/write)
615
+ if (enabled('rest')) {
616
+ try {
617
+ const { createRestApiPlugin } = await import('@objectstack/rest') as any;
618
+ const restPlugin = createRestApiPlugin();
619
+ this.childPlugins.push(restPlugin);
620
+ ctx.logger.info(' ✔ REST API endpoints enabled (CRUD + metadata)');
621
+ } catch {
622
+ ctx.logger.debug(' ℹ @objectstack/rest not installed — skipping REST endpoints');
623
+ }
624
+ }
625
+
626
+ // 8. Dispatcher (auth routes, GraphQL, analytics, packages, storage, automation)
627
+ if (enabled('dispatcher')) {
628
+ try {
629
+ const { createDispatcherPlugin } = await import('@objectstack/runtime') as any;
630
+ const dispatcherPlugin = createDispatcherPlugin();
631
+ this.childPlugins.push(dispatcherPlugin);
632
+ ctx.logger.info(' ✔ Dispatcher enabled (auth, GraphQL, analytics, packages, storage)');
633
+ } catch {
634
+ ctx.logger.debug(' ℹ Dispatcher not available — skipping extended API routes');
635
+ }
636
+ }
637
+
638
+ // Extra user-provided plugins
639
+ if (this.options.extraPlugins) {
640
+ this.childPlugins.push(...this.options.extraPlugins);
641
+ }
642
+
643
+ // Init all child plugins
644
+ for (const plugin of this.childPlugins) {
645
+ try {
646
+ await plugin.init(ctx);
647
+ } catch (err: any) {
648
+ ctx.logger.error(`Failed to init child plugin ${plugin.name}: ${err.message}`);
649
+ }
650
+ }
651
+
652
+ // ── Register contract-compliant dev stubs for remaining services ────
653
+ // The kernel defines 17 core services + 3 security services.
654
+ // Real plugins (ObjectQL, Auth, Security, etc.) already registered some.
655
+ // For any service NOT yet registered, we create a contract-compliant
656
+ // dev stub (implementing the interface from packages/spec/src/contracts/)
657
+ // so that the full kernel service map is populated and downstream code
658
+ // receives correct return types (arrays, booleans, objects — not undefined).
659
+
660
+ const stubNames: string[] = [];
661
+
662
+ for (const svc of CORE_SERVICE_NAMES) {
663
+ if (!enabled(svc)) continue;
664
+ try {
665
+ ctx.getService(svc);
666
+ // Already registered by a real plugin — skip
667
+ } catch {
668
+ const factory = DEV_STUB_FACTORIES[svc];
669
+ ctx.registerService(svc, factory ? factory() : { _dev: true, _serviceName: svc });
670
+ stubNames.push(svc);
671
+ }
672
+ }
673
+
674
+ // Security sub-services (if SecurityPlugin wasn't loaded)
675
+ if (enabled('security')) {
676
+ for (const svc of SECURITY_SERVICE_NAMES) {
677
+ try {
678
+ ctx.getService(svc);
679
+ } catch {
680
+ const factory = DEV_STUB_FACTORIES[svc];
681
+ ctx.registerService(svc, factory ? factory() : { _dev: true, _serviceName: svc });
682
+ stubNames.push(svc);
683
+ }
684
+ }
685
+ }
686
+
687
+ if (stubNames.length > 0) {
688
+ ctx.logger.info(` ✔ Contract-compliant dev stubs registered for: ${stubNames.join(', ')}`);
689
+ }
690
+
691
+ ctx.logger.info(`DevPlugin initialized ${this.childPlugins.length} plugin(s) + ${stubNames.length} dev stub(s)`);
692
+ }
693
+
694
+ /**
695
+ * Start Phase
696
+ *
697
+ * Starts all child plugins and optionally seeds the dev admin user.
698
+ */
699
+ async start(ctx: PluginContext): Promise<void> {
700
+ // Start all child plugins
701
+ for (const plugin of this.childPlugins) {
702
+ if (plugin.start) {
703
+ try {
704
+ await plugin.start(ctx);
705
+ } catch (err: any) {
706
+ ctx.logger.error(`Failed to start child plugin ${plugin.name}: ${err.message}`);
707
+ }
708
+ }
709
+ }
710
+
711
+ // Seed default admin user
712
+ if (this.options.seedAdminUser) {
713
+ await this.seedAdmin(ctx);
714
+ }
715
+
716
+ ctx.logger.info('─────────────────────────────────────────');
717
+ ctx.logger.info('🟢 ObjectStack Dev Server ready');
718
+ ctx.logger.info(` http://localhost:${this.options.port}`);
719
+ ctx.logger.info('');
720
+ ctx.logger.info(' API: /api/v1/data/:object');
721
+ ctx.logger.info(' Metadata: /api/v1/meta/:type/:name');
722
+ ctx.logger.info(' Discovery: /.well-known/objectstack');
723
+ ctx.logger.info('─────────────────────────────────────────');
724
+ }
725
+
726
+ /**
727
+ * Destroy Phase
728
+ *
729
+ * Cleans up all child plugins in reverse order.
730
+ */
731
+ async destroy(): Promise<void> {
732
+ for (const plugin of [...this.childPlugins].reverse()) {
733
+ if (plugin.destroy) {
734
+ try {
735
+ await plugin.destroy();
736
+ } catch {
737
+ // Ignore cleanup errors during dev shutdown
738
+ }
739
+ }
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Seed a default admin user for development.
745
+ */
746
+ private async seedAdmin(ctx: PluginContext): Promise<void> {
747
+ try {
748
+ const dataEngine = ctx.getService<any>('data');
749
+ if (!dataEngine) return;
750
+
751
+ // Check if admin already exists
752
+ const existing = await dataEngine.find('user', {
753
+ filter: { email: 'admin@dev.local' },
754
+ limit: 1,
755
+ }).catch(() => null);
756
+
757
+ if (existing?.length) {
758
+ ctx.logger.debug('Dev admin user already exists');
759
+ return;
760
+ }
761
+
762
+ await dataEngine.insert('user', {
763
+ data: {
764
+ name: 'Admin',
765
+ email: 'admin@dev.local',
766
+ username: 'admin',
767
+ role: 'admin',
768
+ },
769
+ }).catch(() => {
770
+ // Table might not exist yet — that's fine for dev
771
+ });
772
+
773
+ ctx.logger.info('🔑 Dev admin user seeded: admin@dev.local');
774
+ } catch {
775
+ // Non-fatal — user seeding is best-effort
776
+ ctx.logger.debug('Could not seed admin user (data engine may not be ready)');
777
+ }
778
+ }
779
+ }