s3db.js 12.1.0 → 12.2.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.
Files changed (42) hide show
  1. package/README.md +212 -196
  2. package/dist/s3db.cjs.js +1286 -226
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.es.js +1284 -226
  5. package/dist/s3db.es.js.map +1 -1
  6. package/package.json +6 -1
  7. package/src/cli/index.js +954 -43
  8. package/src/cli/migration-manager.js +270 -0
  9. package/src/concerns/calculator.js +0 -4
  10. package/src/concerns/metadata-encoding.js +1 -21
  11. package/src/concerns/plugin-storage.js +17 -4
  12. package/src/concerns/typescript-generator.d.ts +171 -0
  13. package/src/concerns/typescript-generator.js +275 -0
  14. package/src/database.class.js +171 -28
  15. package/src/index.js +15 -9
  16. package/src/plugins/api/index.js +0 -1
  17. package/src/plugins/api/routes/resource-routes.js +86 -1
  18. package/src/plugins/api/server.js +79 -3
  19. package/src/plugins/api/utils/openapi-generator.js +195 -5
  20. package/src/plugins/backup/multi-backup-driver.class.js +0 -1
  21. package/src/plugins/backup.plugin.js +7 -14
  22. package/src/plugins/concerns/plugin-dependencies.js +73 -19
  23. package/src/plugins/eventual-consistency/analytics.js +0 -2
  24. package/src/plugins/eventual-consistency/consolidation.js +2 -13
  25. package/src/plugins/eventual-consistency/index.js +0 -1
  26. package/src/plugins/eventual-consistency/install.js +1 -1
  27. package/src/plugins/geo.plugin.js +5 -6
  28. package/src/plugins/importer/index.js +1 -1
  29. package/src/plugins/relation.plugin.js +11 -11
  30. package/src/plugins/replicator.plugin.js +12 -21
  31. package/src/plugins/s3-queue.plugin.js +4 -4
  32. package/src/plugins/scheduler.plugin.js +10 -12
  33. package/src/plugins/state-machine.plugin.js +8 -12
  34. package/src/plugins/tfstate/README.md +1 -1
  35. package/src/plugins/tfstate/errors.js +3 -3
  36. package/src/plugins/tfstate/index.js +41 -67
  37. package/src/plugins/ttl.plugin.js +3 -3
  38. package/src/resource.class.js +263 -61
  39. package/src/schema.class.js +0 -2
  40. package/src/testing/factory.class.js +286 -0
  41. package/src/testing/index.js +15 -0
  42. package/src/testing/seeder.class.js +183 -0
@@ -299,6 +299,91 @@ export function createResourceRoutes(resource, version, config = {}) {
299
299
  return app;
300
300
  }
301
301
 
302
+ /**
303
+ * Create relational routes for a resource relation
304
+ * @param {Object} sourceResource - Source s3db.js Resource instance
305
+ * @param {string} relationName - Name of the relation (e.g., 'posts', 'profile')
306
+ * @param {Object} relationConfig - Relation configuration from RelationPlugin
307
+ * @param {string} version - Resource version (e.g., 'v1')
308
+ * @returns {Hono} Hono app with relational routes
309
+ */
310
+ export function createRelationalRoutes(sourceResource, relationName, relationConfig, version) {
311
+ const app = new Hono();
312
+ const resourceName = sourceResource.name;
313
+ const relatedResourceName = relationConfig.resource;
314
+
315
+ // GET /{version}/{resource}/:id/{relation}
316
+ // Examples: GET /v1/users/user123/posts, GET /v1/users/user123/profile
317
+ app.get('/:id', asyncHandler(async (c) => {
318
+ const id = c.req.param('id');
319
+ const query = c.req.query();
320
+
321
+ // Check if source resource exists
322
+ const source = await sourceResource.get(id);
323
+ if (!source) {
324
+ const response = formatter.notFound(resourceName, id);
325
+ return c.json(response, response._status);
326
+ }
327
+
328
+ // Use RelationPlugin's include feature to load the relation
329
+ const result = await sourceResource.get(id, {
330
+ include: [relationName]
331
+ });
332
+
333
+ const relatedData = result[relationName];
334
+
335
+ // Check if relation exists
336
+ if (!relatedData) {
337
+ // Return appropriate response based on relation type
338
+ if (relationConfig.type === 'hasMany' || relationConfig.type === 'belongsToMany') {
339
+ // For *-to-many relations, return empty array
340
+ const response = formatter.list([], {
341
+ total: 0,
342
+ page: 1,
343
+ pageSize: 100,
344
+ pageCount: 0
345
+ });
346
+ return c.json(response, response._status);
347
+ } else {
348
+ // For *-to-one relations, return 404
349
+ const response = formatter.notFound(relatedResourceName, 'related resource');
350
+ return c.json(response, response._status);
351
+ }
352
+ }
353
+
354
+ // Return appropriate format based on relation type
355
+ if (relationConfig.type === 'hasMany' || relationConfig.type === 'belongsToMany') {
356
+ // For *-to-many, return list format
357
+ const items = Array.isArray(relatedData) ? relatedData : [relatedData];
358
+ const limit = parseInt(query.limit) || 100;
359
+ const offset = parseInt(query.offset) || 0;
360
+
361
+ // Apply pagination
362
+ const paginatedItems = items.slice(offset, offset + limit);
363
+
364
+ const response = formatter.list(paginatedItems, {
365
+ total: items.length,
366
+ page: Math.floor(offset / limit) + 1,
367
+ pageSize: limit,
368
+ pageCount: Math.ceil(items.length / limit)
369
+ });
370
+
371
+ // Set pagination headers
372
+ c.header('X-Total-Count', items.length.toString());
373
+ c.header('X-Page-Count', Math.ceil(items.length / limit).toString());
374
+
375
+ return c.json(response, response._status);
376
+ } else {
377
+ // For *-to-one, return single resource format
378
+ const response = formatter.success(relatedData);
379
+ return c.json(response, response._status);
380
+ }
381
+ }));
382
+
383
+ return app;
384
+ }
385
+
302
386
  export default {
303
- createResourceRoutes
387
+ createResourceRoutes,
388
+ createRelationalRoutes
304
389
  };
@@ -7,7 +7,7 @@
7
7
  import { Hono } from 'hono';
8
8
  import { serve } from '@hono/node-server';
9
9
  import { swaggerUI } from '@hono/swagger-ui';
10
- import { createResourceRoutes } from './routes/resource-routes.js';
10
+ import { createResourceRoutes, createRelationalRoutes } from './routes/resource-routes.js';
11
11
  import { errorHandler } from './utils/error-handler.js';
12
12
  import * as formatter from './utils/response-formatter.js';
13
13
  import { generateOpenAPISpec } from './utils/openapi-generator.js';
@@ -51,6 +51,11 @@ export class ApiServer {
51
51
  this.isRunning = false;
52
52
  this.openAPISpec = null;
53
53
 
54
+ // Detect if RelationPlugin is installed
55
+ this.relationsPlugin = this.options.database?.plugins?.relation ||
56
+ this.options.database?.plugins?.RelationPlugin ||
57
+ null;
58
+
54
59
  this._setupRoutes();
55
60
  }
56
61
 
@@ -165,12 +170,10 @@ export class ApiServer {
165
170
 
166
171
  // API Documentation UI endpoint
167
172
  if (this.options.docsUI === 'swagger') {
168
- // Swagger UI (legacy, less pretty)
169
173
  this.app.get('/docs', swaggerUI({
170
174
  url: '/openapi.json'
171
175
  }));
172
176
  } else {
173
- // Redoc (modern, beautiful design!)
174
177
  this.app.get('/docs', (c) => {
175
178
  return c.html(`<!DOCTYPE html>
176
179
  <html lang="en">
@@ -197,6 +200,11 @@ export class ApiServer {
197
200
  // Setup resource routes
198
201
  this._setupResourceRoutes();
199
202
 
203
+ // Setup relational routes if RelationPlugin is active
204
+ if (this.relationsPlugin) {
205
+ this._setupRelationalRoutes();
206
+ }
207
+
200
208
  // Global error handler
201
209
  this.app.onError((err, c) => {
202
210
  return errorHandler(err, c);
@@ -257,6 +265,74 @@ export class ApiServer {
257
265
  }
258
266
  }
259
267
 
268
+ /**
269
+ * Setup relational routes (when RelationPlugin is active)
270
+ * @private
271
+ */
272
+ _setupRelationalRoutes() {
273
+ if (!this.relationsPlugin || !this.relationsPlugin.relations) {
274
+ return;
275
+ }
276
+
277
+ const { database } = this.options;
278
+ const relations = this.relationsPlugin.relations;
279
+
280
+ if (this.options.verbose) {
281
+ console.log('[API Plugin] Setting up relational routes...');
282
+ }
283
+
284
+ for (const [resourceName, relationsDef] of Object.entries(relations)) {
285
+ const resource = database.resources[resourceName];
286
+ if (!resource) {
287
+ if (this.options.verbose) {
288
+ console.warn(`[API Plugin] Resource '${resourceName}' not found for relational routes`);
289
+ }
290
+ continue;
291
+ }
292
+
293
+ // Skip plugin resources unless explicitly included
294
+ if (resourceName.startsWith('plg_') && !this.options.resources[resourceName]) {
295
+ continue;
296
+ }
297
+
298
+ const version = resource.config?.currentVersion || resource.version || 'v1';
299
+
300
+ for (const [relationName, relationConfig] of Object.entries(relationsDef)) {
301
+ // Only create routes for relations that should be exposed via API
302
+ // Skip belongsTo relations (they're just reverse lookups, not useful as endpoints)
303
+ if (relationConfig.type === 'belongsTo') {
304
+ continue;
305
+ }
306
+
307
+ // Check if relation should be exposed (default: yes, unless explicitly disabled)
308
+ const resourceConfig = this.options.resources[resourceName];
309
+ const exposeRelation = resourceConfig?.relations?.[relationName]?.expose !== false;
310
+
311
+ if (!exposeRelation) {
312
+ continue;
313
+ }
314
+
315
+ // Create relational routes
316
+ const relationalApp = createRelationalRoutes(
317
+ resource,
318
+ relationName,
319
+ relationConfig,
320
+ version
321
+ );
322
+
323
+ // Mount relational routes at /{version}/{resource}/:id/{relation}
324
+ this.app.route(`/${version}/${resourceName}/:id/${relationName}`, relationalApp);
325
+
326
+ if (this.options.verbose) {
327
+ console.log(
328
+ `[API Plugin] Mounted relational route: /${version}/${resourceName}/:id/${relationName} ` +
329
+ `(${relationConfig.type} -> ${relationConfig.resource})`
330
+ );
331
+ }
332
+ }
333
+ }
334
+ }
335
+
260
336
  /**
261
337
  * Start the server
262
338
  * @returns {Promise<void>}
@@ -100,6 +100,12 @@ function generateResourceSchema(resource) {
100
100
 
101
101
  const attributes = resource.config?.attributes || resource.attributes || {};
102
102
 
103
+ // Extract resource description (supports both string and object format)
104
+ const resourceDescription = resource.config?.description;
105
+ const attributeDescriptions = typeof resourceDescription === 'object'
106
+ ? (resourceDescription.attributes || {})
107
+ : {};
108
+
103
109
  // Add system-generated id field (always present in responses)
104
110
  properties.id = {
105
111
  type: 'string',
@@ -114,7 +120,7 @@ function generateResourceSchema(resource) {
114
120
  const baseType = mapFieldTypeToOpenAPI(fieldDef.type);
115
121
  properties[fieldName] = {
116
122
  ...baseType,
117
- description: fieldDef.description || undefined
123
+ description: fieldDef.description || attributeDescriptions[fieldName] || undefined
118
124
  };
119
125
 
120
126
  if (fieldDef.required) {
@@ -142,7 +148,8 @@ function generateResourceSchema(resource) {
142
148
 
143
149
  properties[fieldName] = {
144
150
  ...baseType,
145
- ...rules
151
+ ...rules,
152
+ description: attributeDescriptions[fieldName] || undefined
146
153
  };
147
154
 
148
155
  if (rules.required) {
@@ -787,6 +794,113 @@ The response includes pagination metadata in the \`pagination\` object with tota
787
794
  return paths;
788
795
  }
789
796
 
797
+ /**
798
+ * Generate OpenAPI paths for relational routes
799
+ * @param {Object} resource - Source s3db.js Resource instance
800
+ * @param {string} relationName - Name of the relation
801
+ * @param {Object} relationConfig - Relation configuration
802
+ * @param {string} version - Resource version
803
+ * @param {Object} relatedSchema - OpenAPI schema for related resource
804
+ * @returns {Object} OpenAPI paths for relation
805
+ */
806
+ function generateRelationalPaths(resource, relationName, relationConfig, version, relatedSchema) {
807
+ const resourceName = resource.name;
808
+ const basePath = `/${version}/${resourceName}/{id}/${relationName}`;
809
+ const relatedResourceName = relationConfig.resource;
810
+ const isToMany = relationConfig.type === 'hasMany' || relationConfig.type === 'belongsToMany';
811
+
812
+ const paths = {};
813
+
814
+ paths[basePath] = {
815
+ get: {
816
+ tags: [resourceName],
817
+ summary: `Get ${relationName} of ${resourceName}`,
818
+ description: `Retrieve ${relationName} (${relationConfig.type}) associated with this ${resourceName}. ` +
819
+ `This endpoint uses the RelationPlugin to efficiently load related data` +
820
+ (relationConfig.partitionHint ? ` via the '${relationConfig.partitionHint}' partition.` : '.'),
821
+ parameters: [
822
+ {
823
+ name: 'id',
824
+ in: 'path',
825
+ required: true,
826
+ description: `${resourceName} ID`,
827
+ schema: { type: 'string' }
828
+ },
829
+ ...(isToMany ? [
830
+ {
831
+ name: 'limit',
832
+ in: 'query',
833
+ description: 'Maximum number of items to return',
834
+ schema: { type: 'integer', default: 100, minimum: 1, maximum: 1000 }
835
+ },
836
+ {
837
+ name: 'offset',
838
+ in: 'query',
839
+ description: 'Number of items to skip',
840
+ schema: { type: 'integer', default: 0, minimum: 0 }
841
+ }
842
+ ] : [])
843
+ ],
844
+ responses: {
845
+ 200: {
846
+ description: 'Successful response',
847
+ content: {
848
+ 'application/json': {
849
+ schema: isToMany ? {
850
+ type: 'object',
851
+ properties: {
852
+ success: { type: 'boolean', example: true },
853
+ data: {
854
+ type: 'array',
855
+ items: relatedSchema
856
+ },
857
+ pagination: {
858
+ type: 'object',
859
+ properties: {
860
+ total: { type: 'integer' },
861
+ page: { type: 'integer' },
862
+ pageSize: { type: 'integer' },
863
+ pageCount: { type: 'integer' }
864
+ }
865
+ }
866
+ }
867
+ } : {
868
+ type: 'object',
869
+ properties: {
870
+ success: { type: 'boolean', example: true },
871
+ data: relatedSchema
872
+ }
873
+ }
874
+ }
875
+ },
876
+ ...(isToMany ? {
877
+ headers: {
878
+ 'X-Total-Count': {
879
+ description: 'Total number of related records',
880
+ schema: { type: 'integer' }
881
+ },
882
+ 'X-Page-Count': {
883
+ description: 'Total number of pages',
884
+ schema: { type: 'integer' }
885
+ }
886
+ }
887
+ } : {})
888
+ },
889
+ 404: {
890
+ description: `${resourceName} not found` + (isToMany ? '' : ' or no related resource exists'),
891
+ content: {
892
+ 'application/json': {
893
+ schema: { $ref: '#/components/schemas/Error' }
894
+ }
895
+ }
896
+ }
897
+ }
898
+ }
899
+ };
900
+
901
+ return paths;
902
+ }
903
+
790
904
  /**
791
905
  * Generate complete OpenAPI 3.0 specification
792
906
  * @param {Object} database - s3db.js Database instance
@@ -803,12 +917,42 @@ export function generateOpenAPISpec(database, config = {}) {
803
917
  resources: resourceConfigs = {}
804
918
  } = config;
805
919
 
920
+ // Build resources table for description
921
+ const resourcesTableRows = [];
922
+ for (const [name, resource] of Object.entries(database.resources)) {
923
+ // Skip plugin resources unless explicitly configured
924
+ if (name.startsWith('plg_') && !resourceConfigs[name]) {
925
+ continue;
926
+ }
927
+
928
+ const version = resource.config?.currentVersion || resource.version || 'v1';
929
+ const resourceDescription = resource.config?.description;
930
+ const descText = typeof resourceDescription === 'object'
931
+ ? resourceDescription.resource
932
+ : resourceDescription || 'No description';
933
+
934
+ resourcesTableRows.push(`| ${name} | ${descText} | \`/${version}/${name}\` |`);
935
+ }
936
+
937
+ // Build enhanced description with resources table
938
+ const enhancedDescription = `${description}
939
+
940
+ ## Available Resources
941
+
942
+ | Resource | Description | Base Path |
943
+ |----------|-------------|-----------|
944
+ ${resourcesTableRows.join('\n')}
945
+
946
+ ---
947
+
948
+ For detailed information about each endpoint, see the sections below.`;
949
+
806
950
  const spec = {
807
951
  openapi: '3.1.0',
808
952
  info: {
809
953
  title,
810
954
  version,
811
- description,
955
+ description: enhancedDescription,
812
956
  contact: {
813
957
  name: 's3db.js',
814
958
  url: 'https://github.com/forattini-dev/s3db.js'
@@ -903,6 +1047,9 @@ export function generateOpenAPISpec(database, config = {}) {
903
1047
  // Generate paths for each resource
904
1048
  const resources = database.resources;
905
1049
 
1050
+ // Detect RelationPlugin
1051
+ const relationsPlugin = database.plugins?.relation || database.plugins?.RelationPlugin || null;
1052
+
906
1053
  for (const [name, resource] of Object.entries(resources)) {
907
1054
  // Skip plugin resources unless explicitly configured
908
1055
  if (name.startsWith('plg_') && !resourceConfigs[name]) {
@@ -924,14 +1071,57 @@ export function generateOpenAPISpec(database, config = {}) {
924
1071
  // Merge paths
925
1072
  Object.assign(spec.paths, paths);
926
1073
 
927
- // Add tag
1074
+ // Add tag with description support
1075
+ const resourceDescription = resource.config?.description;
1076
+ const tagDescription = typeof resourceDescription === 'object'
1077
+ ? resourceDescription.resource
1078
+ : resourceDescription || `Operations for ${name} resource`;
1079
+
928
1080
  spec.tags.push({
929
1081
  name: name,
930
- description: `Operations for ${name} resource`
1082
+ description: tagDescription
931
1083
  });
932
1084
 
933
1085
  // Add schema to components
934
1086
  spec.components.schemas[name] = generateResourceSchema(resource);
1087
+
1088
+ // Generate relational paths if RelationPlugin is active
1089
+ if (relationsPlugin && relationsPlugin.relations && relationsPlugin.relations[name]) {
1090
+ const relationsDef = relationsPlugin.relations[name];
1091
+
1092
+ for (const [relationName, relationConfig] of Object.entries(relationsDef)) {
1093
+ // Skip belongsTo relations (not useful as REST endpoints)
1094
+ if (relationConfig.type === 'belongsTo') {
1095
+ continue;
1096
+ }
1097
+
1098
+ // Check if relation should be exposed (default: yes)
1099
+ const exposeRelation = config?.relations?.[relationName]?.expose !== false;
1100
+ if (!exposeRelation) {
1101
+ continue;
1102
+ }
1103
+
1104
+ // Get related resource schema
1105
+ const relatedResource = database.resources[relationConfig.resource];
1106
+ if (!relatedResource) {
1107
+ continue;
1108
+ }
1109
+
1110
+ const relatedSchema = generateResourceSchema(relatedResource);
1111
+
1112
+ // Generate relational paths
1113
+ const relationalPaths = generateRelationalPaths(
1114
+ resource,
1115
+ relationName,
1116
+ relationConfig,
1117
+ version,
1118
+ relatedSchema
1119
+ );
1120
+
1121
+ // Merge relational paths
1122
+ Object.assign(spec.paths, relationalPaths);
1123
+ }
1124
+ }
935
1125
  }
936
1126
 
937
1127
  // Add authentication endpoints if enabled
@@ -22,7 +22,6 @@ export default class MultiBackupDriver extends BaseBackupDriver {
22
22
  destinations: [],
23
23
  strategy: 'all', // 'all', 'any', 'priority'
24
24
  concurrency: 3,
25
- requireAll: true, // For backward compatibility
26
25
  ...config
27
26
  });
28
27
 
@@ -333,7 +333,7 @@ export class BackupPlugin extends Plugin {
333
333
  };
334
334
 
335
335
  const [ok] = await tryFn(() =>
336
- this.database.resource(this.config.backupMetadataResource).insert(metadata)
336
+ this.database.resources[this.config.backupMetadataResource].insert(metadata)
337
337
  );
338
338
 
339
339
  return metadata;
@@ -341,7 +341,7 @@ export class BackupPlugin extends Plugin {
341
341
 
342
342
  async _updateBackupMetadata(backupId, updates) {
343
343
  const [ok] = await tryFn(() =>
344
- this.database.resource(this.config.backupMetadataResource).update(backupId, updates)
344
+ this.database.resources[this.config.backupMetadataResource].update(backupId, updates)
345
345
  );
346
346
  }
347
347
 
@@ -387,7 +387,7 @@ export class BackupPlugin extends Plugin {
387
387
  let sinceTimestamp = null;
388
388
  if (type === 'incremental') {
389
389
  const [lastBackupOk, , lastBackups] = await tryFn(() =>
390
- this.database.resource(this.config.backupMetadataResource).list({
390
+ this.database.resources[this.config.backupMetadataResource].list({
391
391
  filter: {
392
392
  status: 'completed',
393
393
  type: { $in: ['full', 'incremental'] }
@@ -802,7 +802,7 @@ export class BackupPlugin extends Plugin {
802
802
 
803
803
  // Merge with metadata from database
804
804
  const [metaOk, , metadataRecords] = await tryFn(() =>
805
- this.database.resource(this.config.backupMetadataResource).list({
805
+ this.database.resources[this.config.backupMetadataResource].list({
806
806
  limit: options.limit || 50,
807
807
  sort: { timestamp: -1 }
808
808
  })
@@ -836,7 +836,7 @@ export class BackupPlugin extends Plugin {
836
836
  */
837
837
  async getBackupStatus(backupId) {
838
838
  const [ok, , backup] = await tryFn(() =>
839
- this.database.resource(this.config.backupMetadataResource).get(backupId)
839
+ this.database.resources[this.config.backupMetadataResource].get(backupId)
840
840
  );
841
841
 
842
842
  return ok ? backup : null;
@@ -846,7 +846,7 @@ export class BackupPlugin extends Plugin {
846
846
  try {
847
847
  // Get all completed backups sorted by timestamp
848
848
  const [listOk, , allBackups] = await tryFn(() =>
849
- this.database.resource(this.config.backupMetadataResource).list({
849
+ this.database.resources[this.config.backupMetadataResource].list({
850
850
  filter: { status: 'completed' },
851
851
  sort: { timestamp: -1 }
852
852
  })
@@ -938,7 +938,7 @@ export class BackupPlugin extends Plugin {
938
938
  await this.driver.delete(backup.id, backup.driverInfo);
939
939
 
940
940
  // Delete metadata
941
- await this.database.resource(this.config.backupMetadataResource).delete(backup.id);
941
+ await this.database.resources[this.config.backupMetadataResource].delete(backup.id);
942
942
 
943
943
  if (this.config.verbose) {
944
944
  console.log(`[BackupPlugin] Deleted old backup: ${backup.id}`);
@@ -982,13 +982,6 @@ export class BackupPlugin extends Plugin {
982
982
  await this.driver.cleanup();
983
983
  }
984
984
  }
985
-
986
- /**
987
- * Cleanup plugin resources (alias for stop for backward compatibility)
988
- */
989
- async cleanup() {
990
- await this.stop();
991
- }
992
985
  }
993
986
 
994
987
  export default BackupPlugin;