s3db.js 12.1.0 → 12.2.1

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 (43) hide show
  1. package/README.md +212 -196
  2. package/dist/s3db.cjs.js +1041 -1941
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.es.js +1039 -1941
  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 +5 -2
  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/index.js +2 -1
  30. package/src/plugins/relation.plugin.js +11 -11
  31. package/src/plugins/replicator.plugin.js +12 -21
  32. package/src/plugins/s3-queue.plugin.js +4 -4
  33. package/src/plugins/scheduler.plugin.js +10 -12
  34. package/src/plugins/state-machine.plugin.js +8 -12
  35. package/src/plugins/tfstate/README.md +1 -1
  36. package/src/plugins/tfstate/errors.js +3 -3
  37. package/src/plugins/tfstate/index.js +41 -67
  38. package/src/plugins/ttl.plugin.js +3 -3
  39. package/src/resource.class.js +263 -61
  40. package/src/schema.class.js +0 -2
  41. package/src/testing/factory.class.js +286 -0
  42. package/src/testing/index.js +15 -0
  43. package/src/testing/seeder.class.js +183 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Migration Manager for s3db.js
3
+ * Handles database schema migrations
4
+ */
5
+
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+
9
+ export class MigrationManager {
10
+ constructor(database, migrationsDir = './migrations') {
11
+ this.database = database;
12
+ this.migrationsDir = migrationsDir;
13
+ this.migrationResource = null;
14
+ }
15
+
16
+ /**
17
+ * Initialize migrations system
18
+ */
19
+ async init() {
20
+ // Create migrations resource if it doesn't exist
21
+ const resources = await this.database.listResources();
22
+ const exists = resources.find(r => r.name === '_migrations');
23
+
24
+ if (!exists) {
25
+ this.migrationResource = await this.database.createResource({
26
+ name: '_migrations',
27
+ attributes: {
28
+ id: 'string|required',
29
+ name: 'string|required',
30
+ batch: 'number|default:1',
31
+ executedAt: 'string'
32
+ },
33
+ timestamps: true,
34
+ behavior: 'enforce-limits'
35
+ });
36
+ } else {
37
+ this.migrationResource = await this.database.resource('_migrations');
38
+ }
39
+
40
+ // Ensure migrations directory exists
41
+ try {
42
+ await fs.mkdir(this.migrationsDir, { recursive: true });
43
+ } catch (err) {
44
+ // Directory exists
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Generate a new migration file
50
+ */
51
+ async generate(name) {
52
+ const timestamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0];
53
+ const filename = `${timestamp}_${name}.js`;
54
+ const filepath = path.join(this.migrationsDir, filename);
55
+
56
+ const template = `/**
57
+ * Migration: ${name}
58
+ * Generated: ${new Date().toISOString()}
59
+ */
60
+
61
+ export async function up(database) {
62
+ // Add migration logic here
63
+ // Example:
64
+ // await database.createResource({
65
+ // name: 'users',
66
+ // attributes: {
67
+ // id: 'string|required',
68
+ // email: 'string|required|email',
69
+ // name: 'string|required'
70
+ // },
71
+ // timestamps: true
72
+ // });
73
+ }
74
+
75
+ export async function down(database) {
76
+ // Add rollback logic here
77
+ // Example:
78
+ // await database.deleteResource('users');
79
+ }
80
+ `;
81
+
82
+ await fs.writeFile(filepath, template);
83
+ return { filename, filepath };
84
+ }
85
+
86
+ /**
87
+ * Get all migration files
88
+ */
89
+ async getMigrationFiles() {
90
+ try {
91
+ const files = await fs.readdir(this.migrationsDir);
92
+ return files
93
+ .filter(f => f.endsWith('.js'))
94
+ .sort();
95
+ } catch (err) {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Get executed migrations from database
102
+ */
103
+ async getExecutedMigrations() {
104
+ try {
105
+ const migrations = await this.migrationResource.list();
106
+ return migrations.map(m => m.name);
107
+ } catch (err) {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Get current batch number
114
+ */
115
+ async getCurrentBatch() {
116
+ try {
117
+ const migrations = await this.migrationResource.list();
118
+ if (migrations.length === 0) return 0;
119
+
120
+ const batches = migrations.map(m => m.batch || 0);
121
+ return Math.max(...batches);
122
+ } catch (err) {
123
+ return 0;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Get pending migrations
129
+ */
130
+ async getPendingMigrations() {
131
+ const allFiles = await this.getMigrationFiles();
132
+ const executed = await this.getExecutedMigrations();
133
+
134
+ return allFiles.filter(f => !executed.includes(f));
135
+ }
136
+
137
+ /**
138
+ * Run pending migrations
139
+ */
140
+ async up(options = {}) {
141
+ const { step = null } = options;
142
+ const pending = await this.getPendingMigrations();
143
+
144
+ if (pending.length === 0) {
145
+ return { message: 'No pending migrations', migrations: [] };
146
+ }
147
+
148
+ const toRun = step ? pending.slice(0, step) : pending;
149
+ const batch = (await this.getCurrentBatch()) + 1;
150
+ const executed = [];
151
+
152
+ for (const filename of toRun) {
153
+ const filepath = path.join(process.cwd(), this.migrationsDir, filename);
154
+ const migration = await import(filepath);
155
+
156
+ // Execute up
157
+ await migration.up(this.database);
158
+
159
+ // Record migration
160
+ await this.migrationResource.insert({
161
+ id: filename,
162
+ name: filename,
163
+ batch,
164
+ executedAt: new Date().toISOString()
165
+ });
166
+
167
+ executed.push(filename);
168
+ }
169
+
170
+ return { message: `Executed ${executed.length} migrations`, migrations: executed, batch };
171
+ }
172
+
173
+ /**
174
+ * Rollback migrations
175
+ */
176
+ async down(options = {}) {
177
+ const { step = 1 } = options;
178
+
179
+ const allMigrations = await this.migrationResource.list();
180
+ if (allMigrations.length === 0) {
181
+ return { message: 'No migrations to rollback', migrations: [] };
182
+ }
183
+
184
+ // Sort by batch descending, then by name descending
185
+ allMigrations.sort((a, b) => {
186
+ if (a.batch !== b.batch) return b.batch - a.batch;
187
+ return b.name.localeCompare(a.name);
188
+ });
189
+
190
+ const currentBatch = allMigrations[0].batch;
191
+ const toRollback = allMigrations
192
+ .filter(m => m.batch === currentBatch)
193
+ .slice(0, step);
194
+
195
+ const rolledBack = [];
196
+
197
+ for (const migration of toRollback) {
198
+ const filepath = path.join(process.cwd(), this.migrationsDir, migration.name);
199
+ const migrationModule = await import(filepath);
200
+
201
+ // Execute down
202
+ await migrationModule.down(this.database);
203
+
204
+ // Remove migration record
205
+ await this.migrationResource.delete(migration.id);
206
+
207
+ rolledBack.push(migration.name);
208
+ }
209
+
210
+ return { message: `Rolled back ${rolledBack.length} migrations`, migrations: rolledBack };
211
+ }
212
+
213
+ /**
214
+ * Reset all migrations
215
+ */
216
+ async reset() {
217
+ const allMigrations = await this.migrationResource.list();
218
+
219
+ // Sort in reverse order for rollback
220
+ allMigrations.sort((a, b) => {
221
+ if (a.batch !== b.batch) return b.batch - a.batch;
222
+ return b.name.localeCompare(a.name);
223
+ });
224
+
225
+ const rolledBack = [];
226
+
227
+ for (const migration of allMigrations) {
228
+ const filepath = path.join(process.cwd(), this.migrationsDir, migration.name);
229
+ const migrationModule = await import(filepath);
230
+
231
+ // Execute down
232
+ await migrationModule.down(this.database);
233
+
234
+ // Remove migration record
235
+ await this.migrationResource.delete(migration.id);
236
+
237
+ rolledBack.push(migration.name);
238
+ }
239
+
240
+ return { message: `Reset ${rolledBack.length} migrations`, migrations: rolledBack };
241
+ }
242
+
243
+ /**
244
+ * Get migration status
245
+ */
246
+ async status() {
247
+ const allFiles = await this.getMigrationFiles();
248
+ const executed = await this.getExecutedMigrations();
249
+ const executedRecords = await this.migrationResource.list();
250
+
251
+ const executedMap = {};
252
+ executedRecords.forEach(m => {
253
+ executedMap[m.name] = m;
254
+ });
255
+
256
+ return allFiles.map(filename => {
257
+ const isExecuted = executed.includes(filename);
258
+ const record = executedMap[filename];
259
+
260
+ return {
261
+ name: filename,
262
+ status: isExecuted ? 'executed' : 'pending',
263
+ batch: record?.batch || null,
264
+ executedAt: record?.executedAt || null
265
+ };
266
+ });
267
+ }
268
+ }
269
+
270
+ export default MigrationManager;
@@ -66,10 +66,6 @@ export function clearUTF8Memory() {
66
66
  utf8BytesMemory.clear();
67
67
  }
68
68
 
69
- // Aliases for backward compatibility
70
- export const clearUTF8Memo = clearUTF8Memory;
71
- export const clearUTF8Cache = clearUTF8Memory;
72
-
73
69
  /**
74
70
  * Calculates the size in bytes of attribute names (mapped to digits)
75
71
  * @param {Object} mappedObject - The object returned by schema.mapper()
@@ -464,24 +464,7 @@ export function metadataDecode(value) {
464
464
  }
465
465
  }
466
466
 
467
- // No prefix - return as is (backwards compatibility)
468
- // Try to detect if it's base64 without prefix (legacy)
469
- // OPTIMIZATION: Quick reject before expensive regex
470
- const len = value.length;
471
- if (len > 0 && len % 4 === 0) { // Base64 is always multiple of 4
472
- if (/^[A-Za-z0-9+/]+=*$/.test(value)) {
473
- try {
474
- const decoded = Buffer.from(value, 'base64').toString('utf8');
475
- // Verify it's valid UTF-8 with special chars
476
- if (/[^\x00-\x7F]/.test(decoded) && Buffer.from(decoded, 'utf8').toString('base64') === value) {
477
- return decoded;
478
- }
479
- } catch {
480
- // Not base64, return as is
481
- }
482
- }
483
- }
484
-
467
+ // No prefix - return as is
485
468
  return value;
486
469
  }
487
470
 
@@ -490,9 +473,6 @@ export function metadataDecode(value) {
490
473
  * @param {string} value - Value to calculate size for
491
474
  * @returns {Object} Size information
492
475
  */
493
- // Backwards compatibility exports
494
- export { metadataEncode as smartEncode, metadataDecode as smartDecode };
495
-
496
476
  export function calculateEncodedSize(value) {
497
477
  const analysis = analyzeString(value);
498
478
  const originalSize = Buffer.byteLength(value, 'utf8');
@@ -134,11 +134,24 @@ export class PluginStorage {
134
134
  }
135
135
 
136
136
  /**
137
- * Alias for set() to maintain backward compatibility
138
- * @deprecated Use set() instead
137
+ * Batch set multiple items
138
+ *
139
+ * @param {Array<{key: string, data: Object, options?: Object}>} items - Items to save
140
+ * @returns {Promise<Array<{ok: boolean, key: string, error?: Error}>>} Results
139
141
  */
140
- async put(key, data, options = {}) {
141
- return this.set(key, data, options);
142
+ async batchSet(items) {
143
+ const results = [];
144
+
145
+ for (const item of items) {
146
+ try {
147
+ await this.set(item.key, item.data, item.options || {});
148
+ results.push({ ok: true, key: item.key });
149
+ } catch (error) {
150
+ results.push({ ok: false, key: item.key, error });
151
+ }
152
+ }
153
+
154
+ return results;
142
155
  }
143
156
 
144
157
  /**
@@ -0,0 +1,171 @@
1
+ /**
2
+ * TypeScript Definition Generator for s3db.js
3
+ *
4
+ * Automatically generates type-safe interfaces from your s3db.js resources.
5
+ *
6
+ * @module s3db.js/typescript-generator
7
+ * @example
8
+ * ```typescript
9
+ * import { Database } from 's3db.js';
10
+ * import { generateTypes } from 's3db.js/typescript-generator';
11
+ *
12
+ * const db = new Database({ connectionString: '...' });
13
+ *
14
+ * await db.createResource({
15
+ * name: 'users',
16
+ * attributes: {
17
+ * name: 'string|required',
18
+ * email: 'string|required|email',
19
+ * age: 'number'
20
+ * }
21
+ * });
22
+ *
23
+ * // Generate TypeScript definitions
24
+ * await generateTypes(db, { outputPath: './types/database.d.ts' });
25
+ *
26
+ * // Now you get full autocomplete!
27
+ * import { Users } from './types/database';
28
+ * const user: Users = await db.resources.users.get('id');
29
+ * ```
30
+ */
31
+
32
+ import type { Database } from 's3db.js';
33
+
34
+ /**
35
+ * Options for generating TypeScript definitions
36
+ */
37
+ export interface GenerateTypesOptions {
38
+ /**
39
+ * Output path for the generated .d.ts file
40
+ * @example './types/database.d.ts'
41
+ */
42
+ outputPath: string;
43
+
44
+ /**
45
+ * Module name to augment with resource types
46
+ * @default 's3db.js'
47
+ */
48
+ moduleName?: string;
49
+
50
+ /**
51
+ * Include JSDoc comments in generated types
52
+ * @default true
53
+ */
54
+ includeComments?: boolean;
55
+
56
+ /**
57
+ * Custom header comment to add to generated file
58
+ * @example 'Auto-generated types for MyApp database'
59
+ */
60
+ header?: string;
61
+
62
+ /**
63
+ * Include resource metadata in generated types
64
+ * @default false
65
+ */
66
+ includeMetadata?: boolean;
67
+
68
+ /**
69
+ * Format style for generated code
70
+ * @default 'prettier'
71
+ */
72
+ formatStyle?: 'prettier' | 'compact' | 'none';
73
+ }
74
+
75
+ /**
76
+ * Result of type generation
77
+ */
78
+ export interface GenerateTypesResult {
79
+ /**
80
+ * Number of resource interfaces generated
81
+ */
82
+ resourceCount: number;
83
+
84
+ /**
85
+ * List of generated resource names
86
+ */
87
+ resources: string[];
88
+
89
+ /**
90
+ * Output file path
91
+ */
92
+ outputPath: string;
93
+
94
+ /**
95
+ * Generated content (before writing to file)
96
+ */
97
+ content: string;
98
+ }
99
+
100
+ /**
101
+ * Generate TypeScript definitions from database resources
102
+ *
103
+ * Creates a .d.ts file with type-safe interfaces for all resources in the database.
104
+ * The generated file includes:
105
+ * - Interface for each resource with all fields typed
106
+ * - ResourceMap interface for type-safe db.resources access
107
+ * - Module augmentation for s3db.js Database class
108
+ *
109
+ * @param database - s3db.js Database instance with resources
110
+ * @param options - Generation options
111
+ * @returns Promise<void>
112
+ *
113
+ * @throws {Error} If database has no resources
114
+ * @throws {Error} If output directory doesn't exist
115
+ *
116
+ * @example
117
+ * ```typescript
118
+ * // Basic usage
119
+ * await generateTypes(db, { outputPath: './types/db.d.ts' });
120
+ *
121
+ * // With custom options
122
+ * await generateTypes(db, {
123
+ * outputPath: './types/db.d.ts',
124
+ * moduleName: 's3db.js',
125
+ * includeComments: true,
126
+ * header: 'Generated types for MyApp'
127
+ * });
128
+ * ```
129
+ */
130
+ export function generateTypes(
131
+ database: Database,
132
+ options: GenerateTypesOptions
133
+ ): Promise<void>;
134
+
135
+ /**
136
+ * Map s3db.js field types to TypeScript types
137
+ *
138
+ * @param fieldType - s3db.js field type string (e.g., 'string|required', 'number', 'embedding:1536')
139
+ * @returns TypeScript type string
140
+ *
141
+ * @internal
142
+ *
143
+ * @example
144
+ * ```typescript
145
+ * mapFieldTypeToTypeScript('string|required') // 'string'
146
+ * mapFieldTypeToTypeScript('number') // 'number'
147
+ * mapFieldTypeToTypeScript('embedding:1536') // 'number[] /* 1536 dimensions *\/'
148
+ * ```
149
+ */
150
+ export function mapFieldTypeToTypeScript(fieldType: string): string;
151
+
152
+ /**
153
+ * Check if a field is optional based on its validation rules
154
+ *
155
+ * @param fieldType - s3db.js field type string
156
+ * @returns True if field is optional (no 'required' rule)
157
+ *
158
+ * @internal
159
+ */
160
+ export function isFieldOptional(fieldType: string): boolean;
161
+
162
+ /**
163
+ * Generate JSDoc comment for a field
164
+ *
165
+ * @param fieldName - Name of the field
166
+ * @param fieldType - s3db.js field type string
167
+ * @returns JSDoc comment string
168
+ *
169
+ * @internal
170
+ */
171
+ export function generateFieldComment(fieldName: string, fieldType: string): string;