s3db.js 12.0.1 → 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.
- package/README.md +212 -196
- package/dist/s3db.cjs.js +1431 -4001
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1426 -3997
- package/dist/s3db.es.js.map +1 -1
- package/mcp/entrypoint.js +91 -57
- package/package.json +7 -1
- package/src/cli/index.js +954 -43
- package/src/cli/migration-manager.js +270 -0
- package/src/concerns/calculator.js +0 -4
- package/src/concerns/metadata-encoding.js +1 -21
- package/src/concerns/plugin-storage.js +17 -4
- package/src/concerns/typescript-generator.d.ts +171 -0
- package/src/concerns/typescript-generator.js +275 -0
- package/src/database.class.js +171 -28
- package/src/index.js +15 -9
- package/src/plugins/api/index.js +0 -1
- package/src/plugins/api/routes/resource-routes.js +86 -1
- package/src/plugins/api/server.js +79 -3
- package/src/plugins/api/utils/openapi-generator.js +195 -5
- package/src/plugins/backup/multi-backup-driver.class.js +0 -1
- package/src/plugins/backup.plugin.js +7 -14
- package/src/plugins/concerns/plugin-dependencies.js +73 -19
- package/src/plugins/eventual-consistency/analytics.js +0 -2
- package/src/plugins/eventual-consistency/consolidation.js +2 -13
- package/src/plugins/eventual-consistency/index.js +0 -1
- package/src/plugins/eventual-consistency/install.js +1 -1
- package/src/plugins/geo.plugin.js +5 -6
- package/src/plugins/importer/index.js +1 -1
- package/src/plugins/plugin.class.js +5 -0
- package/src/plugins/relation.plugin.js +193 -57
- package/src/plugins/replicator.plugin.js +12 -21
- package/src/plugins/s3-queue.plugin.js +4 -4
- package/src/plugins/scheduler.plugin.js +10 -12
- package/src/plugins/state-machine.plugin.js +8 -12
- package/src/plugins/tfstate/README.md +1 -1
- package/src/plugins/tfstate/errors.js +3 -3
- package/src/plugins/tfstate/index.js +41 -67
- package/src/plugins/ttl.plugin.js +479 -304
- package/src/resource.class.js +263 -61
- package/src/schema.class.js +0 -2
- package/src/testing/factory.class.js +286 -0
- package/src/testing/index.js +15 -0
- package/src/testing/seeder.class.js +183 -0
- package/dist/s3db-cli.js +0 -55543
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript Definition Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates .d.ts files from s3db.js resource schemas for type safety and autocomplete.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { generateTypes } from 's3db.js/typescript-generator';
|
|
8
|
+
* await generateTypes(database, { outputPath: './types/database.d.ts' });
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Auto-generates TypeScript interfaces from resource schemas
|
|
12
|
+
* - Type-safe property access (db.resources.users)
|
|
13
|
+
* - Autocomplete for resource methods
|
|
14
|
+
* - Detects typos at compile time (user.emai → error!)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
18
|
+
import { dirname } from 'path';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Map s3db.js field types to TypeScript types
|
|
22
|
+
* @param {string} fieldType - s3db.js field type
|
|
23
|
+
* @returns {string} TypeScript type
|
|
24
|
+
*/
|
|
25
|
+
function mapFieldTypeToTypeScript(fieldType) {
|
|
26
|
+
// Extract base type from validation rules (e.g., "string|required" → "string")
|
|
27
|
+
const baseType = fieldType.split('|')[0].trim();
|
|
28
|
+
|
|
29
|
+
const typeMap = {
|
|
30
|
+
'string': 'string',
|
|
31
|
+
'number': 'number',
|
|
32
|
+
'integer': 'number',
|
|
33
|
+
'boolean': 'boolean',
|
|
34
|
+
'array': 'any[]',
|
|
35
|
+
'object': 'Record<string, any>',
|
|
36
|
+
'json': 'Record<string, any>',
|
|
37
|
+
'secret': 'string',
|
|
38
|
+
'email': 'string',
|
|
39
|
+
'url': 'string',
|
|
40
|
+
'date': 'string', // ISO date string
|
|
41
|
+
'datetime': 'string', // ISO datetime string
|
|
42
|
+
'ip4': 'string',
|
|
43
|
+
'ip6': 'string',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Handle embedding:N notation
|
|
47
|
+
if (baseType.startsWith('embedding:')) {
|
|
48
|
+
const dimensions = parseInt(baseType.split(':')[1]);
|
|
49
|
+
return `number[] /* ${dimensions} dimensions */`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return typeMap[baseType] || 'any';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if field is required based on validation rules
|
|
57
|
+
* @param {string} fieldDef - Field definition
|
|
58
|
+
* @returns {boolean} True if required
|
|
59
|
+
*/
|
|
60
|
+
function isFieldRequired(fieldDef) {
|
|
61
|
+
if (typeof fieldDef === 'string') {
|
|
62
|
+
return fieldDef.includes('|required');
|
|
63
|
+
}
|
|
64
|
+
if (typeof fieldDef === 'object' && fieldDef.required) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Generate TypeScript interface for a resource
|
|
72
|
+
* @param {string} resourceName - Resource name
|
|
73
|
+
* @param {Object} attributes - Resource attributes
|
|
74
|
+
* @param {boolean} timestamps - Whether timestamps are enabled
|
|
75
|
+
* @returns {string} TypeScript interface definition
|
|
76
|
+
*/
|
|
77
|
+
function generateResourceInterface(resourceName, attributes, timestamps = false) {
|
|
78
|
+
const interfaceName = toPascalCase(resourceName);
|
|
79
|
+
const lines = [];
|
|
80
|
+
|
|
81
|
+
lines.push(`export interface ${interfaceName} {`);
|
|
82
|
+
|
|
83
|
+
// Add id field (always present)
|
|
84
|
+
lines.push(` /** Resource ID (auto-generated) */`);
|
|
85
|
+
lines.push(` id: string;`);
|
|
86
|
+
lines.push('');
|
|
87
|
+
|
|
88
|
+
// Add user-defined attributes
|
|
89
|
+
for (const [fieldName, fieldDef] of Object.entries(attributes)) {
|
|
90
|
+
const required = isFieldRequired(fieldDef);
|
|
91
|
+
const optional = required ? '' : '?';
|
|
92
|
+
|
|
93
|
+
// Extract type
|
|
94
|
+
let tsType;
|
|
95
|
+
if (typeof fieldDef === 'string') {
|
|
96
|
+
tsType = mapFieldTypeToTypeScript(fieldDef);
|
|
97
|
+
} else if (typeof fieldDef === 'object' && fieldDef.type) {
|
|
98
|
+
tsType = mapFieldTypeToTypeScript(fieldDef.type);
|
|
99
|
+
|
|
100
|
+
// Handle nested objects
|
|
101
|
+
if (fieldDef.type === 'object' && fieldDef.props) {
|
|
102
|
+
tsType = '{\n';
|
|
103
|
+
for (const [propName, propDef] of Object.entries(fieldDef.props)) {
|
|
104
|
+
const propType = typeof propDef === 'string'
|
|
105
|
+
? mapFieldTypeToTypeScript(propDef)
|
|
106
|
+
: mapFieldTypeToTypeScript(propDef.type);
|
|
107
|
+
const propRequired = isFieldRequired(propDef);
|
|
108
|
+
tsType += ` ${propName}${propRequired ? '' : '?'}: ${propType};\n`;
|
|
109
|
+
}
|
|
110
|
+
tsType += ' }';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Handle arrays with typed items
|
|
114
|
+
if (fieldDef.type === 'array' && fieldDef.items) {
|
|
115
|
+
const itemType = mapFieldTypeToTypeScript(fieldDef.items);
|
|
116
|
+
tsType = `Array<${itemType}>`;
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
tsType = 'any';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Add JSDoc comment if description exists
|
|
123
|
+
if (fieldDef.description) {
|
|
124
|
+
lines.push(` /** ${fieldDef.description} */`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
lines.push(` ${fieldName}${optional}: ${tsType};`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Add timestamp fields if enabled
|
|
131
|
+
if (timestamps) {
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push(` /** Creation timestamp (ISO 8601) */`);
|
|
134
|
+
lines.push(` createdAt: string;`);
|
|
135
|
+
lines.push(` /** Last update timestamp (ISO 8601) */`);
|
|
136
|
+
lines.push(` updatedAt: string;`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
lines.push('}');
|
|
140
|
+
lines.push('');
|
|
141
|
+
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Convert string to PascalCase
|
|
147
|
+
* @param {string} str - String to convert
|
|
148
|
+
* @returns {string} PascalCase string
|
|
149
|
+
*/
|
|
150
|
+
function toPascalCase(str) {
|
|
151
|
+
return str
|
|
152
|
+
.split(/[_-]/)
|
|
153
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
154
|
+
.join('');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Generate TypeScript definitions for all resources
|
|
159
|
+
* @param {Database} database - s3db.js Database instance
|
|
160
|
+
* @param {Object} options - Generation options
|
|
161
|
+
* @param {string} options.outputPath - Output file path (default: ./types/database.d.ts)
|
|
162
|
+
* @param {string} options.moduleName - Module name for import (default: s3db.js)
|
|
163
|
+
* @param {boolean} options.includeResource - Include Resource class methods (default: true)
|
|
164
|
+
* @returns {Promise<string>} Generated TypeScript definitions
|
|
165
|
+
*/
|
|
166
|
+
export async function generateTypes(database, options = {}) {
|
|
167
|
+
const {
|
|
168
|
+
outputPath = './types/database.d.ts',
|
|
169
|
+
moduleName = 's3db.js',
|
|
170
|
+
includeResource = true
|
|
171
|
+
} = options;
|
|
172
|
+
|
|
173
|
+
const lines = [];
|
|
174
|
+
|
|
175
|
+
// File header
|
|
176
|
+
lines.push('/**');
|
|
177
|
+
lines.push(' * Auto-generated TypeScript definitions for s3db.js resources');
|
|
178
|
+
lines.push(' * Generated at: ' + new Date().toISOString());
|
|
179
|
+
lines.push(' * DO NOT EDIT - This file is auto-generated');
|
|
180
|
+
lines.push(' */');
|
|
181
|
+
lines.push('');
|
|
182
|
+
|
|
183
|
+
// Import base types from s3db.js
|
|
184
|
+
if (includeResource) {
|
|
185
|
+
lines.push(`import { Resource, Database } from '${moduleName}';`);
|
|
186
|
+
lines.push('');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Generate interfaces for each resource
|
|
190
|
+
const resourceInterfaces = [];
|
|
191
|
+
|
|
192
|
+
for (const [name, resource] of Object.entries(database.resources)) {
|
|
193
|
+
const attributes = resource.config?.attributes || resource.attributes || {};
|
|
194
|
+
const timestamps = resource.config?.timestamps || false;
|
|
195
|
+
|
|
196
|
+
const interfaceDef = generateResourceInterface(name, attributes, timestamps);
|
|
197
|
+
lines.push(interfaceDef);
|
|
198
|
+
|
|
199
|
+
resourceInterfaces.push({
|
|
200
|
+
name,
|
|
201
|
+
interfaceName: toPascalCase(name),
|
|
202
|
+
resource
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Generate ResourceMap interface for db.resources
|
|
207
|
+
lines.push('/**');
|
|
208
|
+
lines.push(' * Typed resource map for property access');
|
|
209
|
+
lines.push(' * @example');
|
|
210
|
+
lines.push(' * const users = db.resources.users; // Type-safe!');
|
|
211
|
+
lines.push(' * const user = await users.get("id"); // Autocomplete works!');
|
|
212
|
+
lines.push(' */');
|
|
213
|
+
lines.push('export interface ResourceMap {');
|
|
214
|
+
|
|
215
|
+
for (const { name, interfaceName } of resourceInterfaces) {
|
|
216
|
+
lines.push(` /** ${interfaceName} resource */`);
|
|
217
|
+
if (includeResource) {
|
|
218
|
+
lines.push(` ${name}: Resource<${interfaceName}>;`);
|
|
219
|
+
} else {
|
|
220
|
+
lines.push(` ${name}: any;`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
lines.push('}');
|
|
225
|
+
lines.push('');
|
|
226
|
+
|
|
227
|
+
// Generate Database extension with typed resources property
|
|
228
|
+
if (includeResource) {
|
|
229
|
+
lines.push('/**');
|
|
230
|
+
lines.push(' * Extended Database class with typed resources');
|
|
231
|
+
lines.push(' */');
|
|
232
|
+
lines.push('declare module \'s3db.js\' {');
|
|
233
|
+
lines.push(' interface Database {');
|
|
234
|
+
lines.push(' resources: ResourceMap;');
|
|
235
|
+
lines.push(' }');
|
|
236
|
+
lines.push('');
|
|
237
|
+
lines.push(' interface Resource<T = any> {');
|
|
238
|
+
lines.push(' get(id: string): Promise<T>;');
|
|
239
|
+
lines.push(' getOrNull(id: string): Promise<T | null>;');
|
|
240
|
+
lines.push(' getOrThrow(id: string): Promise<T>;');
|
|
241
|
+
lines.push(' insert(data: Partial<T>): Promise<T>;');
|
|
242
|
+
lines.push(' update(id: string, data: Partial<T>): Promise<T>;');
|
|
243
|
+
lines.push(' patch(id: string, data: Partial<T>): Promise<T>;');
|
|
244
|
+
lines.push(' replace(id: string, data: Partial<T>): Promise<T>;');
|
|
245
|
+
lines.push(' delete(id: string): Promise<void>;');
|
|
246
|
+
lines.push(' list(options?: any): Promise<T[]>;');
|
|
247
|
+
lines.push(' query(filters: Partial<T>, options?: any): Promise<T[]>;');
|
|
248
|
+
lines.push(' validate(data: Partial<T>, options?: any): Promise<{ valid: boolean; errors: any[]; data: T | null }>;');
|
|
249
|
+
lines.push(' }');
|
|
250
|
+
lines.push('}');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const content = lines.join('\n');
|
|
254
|
+
|
|
255
|
+
// Write to file if outputPath provided
|
|
256
|
+
if (outputPath) {
|
|
257
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
258
|
+
await writeFile(outputPath, content, 'utf-8');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return content;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Generate types and log to console
|
|
266
|
+
* @param {Database} database - s3db.js Database instance
|
|
267
|
+
* @param {Object} options - Generation options
|
|
268
|
+
*/
|
|
269
|
+
export async function printTypes(database, options = {}) {
|
|
270
|
+
const types = await generateTypes(database, { ...options, outputPath: null });
|
|
271
|
+
console.log(types);
|
|
272
|
+
return types;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export default { generateTypes, printTypes };
|
package/src/database.class.js
CHANGED
|
@@ -18,19 +18,47 @@ export class Database extends EventEmitter {
|
|
|
18
18
|
this.version = "1";
|
|
19
19
|
// Version is injected during build, fallback to "latest" for development
|
|
20
20
|
this.s3dbVersion = (() => {
|
|
21
|
-
const [ok, err, version] = tryFn(() => (typeof __PACKAGE_VERSION__ !== 'undefined' && __PACKAGE_VERSION__ !== '__PACKAGE_VERSION__'
|
|
22
|
-
? __PACKAGE_VERSION__
|
|
21
|
+
const [ok, err, version] = tryFn(() => (typeof __PACKAGE_VERSION__ !== 'undefined' && __PACKAGE_VERSION__ !== '__PACKAGE_VERSION__'
|
|
22
|
+
? __PACKAGE_VERSION__
|
|
23
23
|
: "latest"));
|
|
24
24
|
return ok ? version : "latest";
|
|
25
25
|
})();
|
|
26
|
-
|
|
26
|
+
|
|
27
|
+
// Create Proxy for resources to enable property access (db.resources.users)
|
|
28
|
+
this._resourcesMap = {};
|
|
29
|
+
this.resources = new Proxy(this._resourcesMap, {
|
|
30
|
+
get: (target, prop) => {
|
|
31
|
+
// Allow standard Object methods
|
|
32
|
+
if (typeof prop === 'symbol' || prop === 'constructor' || prop === 'toJSON') {
|
|
33
|
+
return target[prop];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Return resource if exists
|
|
37
|
+
if (target[prop]) {
|
|
38
|
+
return target[prop];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Return undefined for non-existent resources (enables optional chaining)
|
|
42
|
+
return undefined;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// Support Object.keys(), Object.entries(), etc.
|
|
46
|
+
ownKeys: (target) => {
|
|
47
|
+
return Object.keys(target);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
getOwnPropertyDescriptor: (target, prop) => {
|
|
51
|
+
return Object.getOwnPropertyDescriptor(target, prop);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
27
55
|
this.savedMetadata = null; // Store loaded metadata for versioning
|
|
28
56
|
this.options = options;
|
|
29
57
|
this.verbose = options.verbose || false;
|
|
30
58
|
this.parallelism = parseInt(options.parallelism + "") || 10;
|
|
31
|
-
this.
|
|
32
|
-
this.pluginRegistry = {};
|
|
33
|
-
this.
|
|
59
|
+
this.pluginList = options.plugins || [];
|
|
60
|
+
this.pluginRegistry = {};
|
|
61
|
+
this.plugins = this.pluginRegistry; // Alias for plugin registry
|
|
34
62
|
this.cache = options.cache;
|
|
35
63
|
this.passphrase = options.passphrase || "secret";
|
|
36
64
|
this.versioningEnabled = options.versioningEnabled || false;
|
|
@@ -181,7 +209,7 @@ export class Database extends EventEmitter {
|
|
|
181
209
|
restoredIdSize = versionData.idSize || 22;
|
|
182
210
|
}
|
|
183
211
|
|
|
184
|
-
this.
|
|
212
|
+
this._resourcesMap[name] = new Resource({
|
|
185
213
|
name,
|
|
186
214
|
client: this.client,
|
|
187
215
|
database: this, // ensure reference
|
|
@@ -259,7 +287,7 @@ export class Database extends EventEmitter {
|
|
|
259
287
|
|
|
260
288
|
// Check for deleted resources
|
|
261
289
|
for (const [name, savedResource] of Object.entries(savedMetadata.resources || {})) {
|
|
262
|
-
if (!this.
|
|
290
|
+
if (!this._resourcesMap[name]) {
|
|
263
291
|
const currentVersion = savedResource.currentVersion || 'v1';
|
|
264
292
|
const versionData = savedResource.versions?.[currentVersion];
|
|
265
293
|
changes.push({
|
|
@@ -894,7 +922,7 @@ export class Database extends EventEmitter {
|
|
|
894
922
|
* @returns {boolean} True if resource exists, false otherwise
|
|
895
923
|
*/
|
|
896
924
|
resourceExists(name) {
|
|
897
|
-
return !!this.
|
|
925
|
+
return !!this._resourcesMap[name];
|
|
898
926
|
}
|
|
899
927
|
|
|
900
928
|
/**
|
|
@@ -903,17 +931,16 @@ export class Database extends EventEmitter {
|
|
|
903
931
|
* @param {string} config.name - Resource name
|
|
904
932
|
* @param {Object} config.attributes - Resource attributes
|
|
905
933
|
* @param {string} [config.behavior] - Resource behavior
|
|
906
|
-
* @param {Object} [config.options] - Resource options (deprecated, use root level parameters)
|
|
907
934
|
* @returns {Object} Result with exists and hash information
|
|
908
935
|
*/
|
|
909
|
-
resourceExistsWithSameHash({ name, attributes, behavior = 'user-managed', partitions = {}
|
|
910
|
-
if (!this.
|
|
936
|
+
resourceExistsWithSameHash({ name, attributes, behavior = 'user-managed', partitions = {} }) {
|
|
937
|
+
if (!this._resourcesMap[name]) {
|
|
911
938
|
return { exists: false, sameHash: false, hash: null };
|
|
912
939
|
}
|
|
913
940
|
|
|
914
|
-
const existingResource = this.
|
|
941
|
+
const existingResource = this._resourcesMap[name];
|
|
915
942
|
const existingHash = this.generateDefinitionHash(existingResource.export());
|
|
916
|
-
|
|
943
|
+
|
|
917
944
|
// Create a mock resource to calculate the new hash
|
|
918
945
|
const mockResource = new Resource({
|
|
919
946
|
name,
|
|
@@ -923,8 +950,7 @@ export class Database extends EventEmitter {
|
|
|
923
950
|
client: this.client,
|
|
924
951
|
version: existingResource.version,
|
|
925
952
|
passphrase: this.passphrase,
|
|
926
|
-
versioningEnabled: this.versioningEnabled
|
|
927
|
-
...options
|
|
953
|
+
versioningEnabled: this.versioningEnabled
|
|
928
954
|
});
|
|
929
955
|
|
|
930
956
|
const newHash = this.generateDefinitionHash(mockResource.export());
|
|
@@ -955,13 +981,67 @@ export class Database extends EventEmitter {
|
|
|
955
981
|
* @param {string} [config.createdBy='user'] - Who created this resource ('user', 'plugin', or plugin name)
|
|
956
982
|
* @returns {Promise<Resource>} The created or updated resource
|
|
957
983
|
*/
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
984
|
+
/**
|
|
985
|
+
* Normalize partitions config from array or object format
|
|
986
|
+
* @param {Array|Object} partitions - Partitions config
|
|
987
|
+
* @param {Object} attributes - Resource attributes
|
|
988
|
+
* @returns {Object} Normalized partitions object
|
|
989
|
+
* @private
|
|
990
|
+
*/
|
|
991
|
+
_normalizePartitions(partitions, attributes) {
|
|
992
|
+
// If already an object, return as-is
|
|
993
|
+
if (!Array.isArray(partitions)) {
|
|
994
|
+
return partitions || {};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Transform array into object with auto-generated names
|
|
998
|
+
const normalized = {};
|
|
999
|
+
|
|
1000
|
+
for (const fieldName of partitions) {
|
|
1001
|
+
if (typeof fieldName !== 'string') {
|
|
1002
|
+
throw new Error(`Partition field must be a string, got ${typeof fieldName}`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (!attributes[fieldName]) {
|
|
1006
|
+
throw new Error(`Partition field '${fieldName}' not found in attributes`);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Generate partition name: byFieldName (capitalize first letter)
|
|
1010
|
+
const partitionName = `by${fieldName.charAt(0).toUpperCase()}${fieldName.slice(1)}`;
|
|
1011
|
+
|
|
1012
|
+
// Extract field type from attributes
|
|
1013
|
+
const fieldDef = attributes[fieldName];
|
|
1014
|
+
let fieldType = 'string'; // default
|
|
1015
|
+
|
|
1016
|
+
if (typeof fieldDef === 'string') {
|
|
1017
|
+
// String format: "string|required" -> extract "string"
|
|
1018
|
+
fieldType = fieldDef.split('|')[0].trim();
|
|
1019
|
+
} else if (typeof fieldDef === 'object' && fieldDef.type) {
|
|
1020
|
+
// Object format: { type: 'string', required: true }
|
|
1021
|
+
fieldType = fieldDef.type;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
normalized[partitionName] = {
|
|
1025
|
+
fields: {
|
|
1026
|
+
[fieldName]: fieldType
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
return normalized;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async createResource({ name, attributes, behavior = 'user-managed', hooks, middlewares, ...config }) {
|
|
1035
|
+
// Normalize partitions (support array shorthand)
|
|
1036
|
+
const normalizedPartitions = this._normalizePartitions(config.partitions, attributes);
|
|
1037
|
+
|
|
1038
|
+
if (this._resourcesMap[name]) {
|
|
1039
|
+
const existingResource = this._resourcesMap[name];
|
|
961
1040
|
// Update configuration
|
|
962
1041
|
Object.assign(existingResource.config, {
|
|
963
1042
|
cache: this.cache,
|
|
964
1043
|
...config,
|
|
1044
|
+
partitions: normalizedPartitions
|
|
965
1045
|
});
|
|
966
1046
|
if (behavior) {
|
|
967
1047
|
existingResource.behavior = behavior;
|
|
@@ -981,6 +1061,11 @@ export class Database extends EventEmitter {
|
|
|
981
1061
|
}
|
|
982
1062
|
}
|
|
983
1063
|
}
|
|
1064
|
+
// Apply middlewares if provided
|
|
1065
|
+
if (middlewares) {
|
|
1066
|
+
this._applyMiddlewares(existingResource, middlewares);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
984
1069
|
// Only upload metadata if hash actually changed
|
|
985
1070
|
const newHash = this.generateDefinitionHash(existingResource.export(), existingResource.behavior);
|
|
986
1071
|
const existingMetadata = this.savedMetadata?.resources?.[name];
|
|
@@ -1005,7 +1090,7 @@ export class Database extends EventEmitter {
|
|
|
1005
1090
|
observers: [this],
|
|
1006
1091
|
cache: config.cache !== undefined ? config.cache : this.cache,
|
|
1007
1092
|
timestamps: config.timestamps !== undefined ? config.timestamps : false,
|
|
1008
|
-
partitions:
|
|
1093
|
+
partitions: normalizedPartitions,
|
|
1009
1094
|
paranoid: config.paranoid !== undefined ? config.paranoid : true,
|
|
1010
1095
|
allNestedObjectsOptional: config.allNestedObjectsOptional !== undefined ? config.allNestedObjectsOptional : true,
|
|
1011
1096
|
autoDecrypt: config.autoDecrypt !== undefined ? config.autoDecrypt : true,
|
|
@@ -1021,18 +1106,76 @@ export class Database extends EventEmitter {
|
|
|
1021
1106
|
createdBy: config.createdBy || 'user'
|
|
1022
1107
|
});
|
|
1023
1108
|
resource.database = this;
|
|
1024
|
-
this.
|
|
1109
|
+
this._resourcesMap[name] = resource;
|
|
1110
|
+
|
|
1111
|
+
// Apply middlewares if provided
|
|
1112
|
+
if (middlewares) {
|
|
1113
|
+
this._applyMiddlewares(resource, middlewares);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1025
1116
|
await this.uploadMetadataFile();
|
|
1026
1117
|
this.emit("s3db.resourceCreated", name);
|
|
1027
1118
|
return resource;
|
|
1028
1119
|
}
|
|
1029
1120
|
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1121
|
+
/**
|
|
1122
|
+
* Apply middlewares to a resource
|
|
1123
|
+
* @param {Resource} resource - Resource instance
|
|
1124
|
+
* @param {Array|Object} middlewares - Middlewares config
|
|
1125
|
+
* @private
|
|
1126
|
+
*/
|
|
1127
|
+
_applyMiddlewares(resource, middlewares) {
|
|
1128
|
+
// Format 1: Array of functions (applies to all methods)
|
|
1129
|
+
if (Array.isArray(middlewares)) {
|
|
1130
|
+
// Apply to all middleware-supported methods
|
|
1131
|
+
const methods = resource._middlewareMethods || [
|
|
1132
|
+
'get', 'list', 'listIds', 'getAll', 'count', 'page',
|
|
1133
|
+
'insert', 'update', 'delete', 'deleteMany', 'exists', 'getMany',
|
|
1134
|
+
'content', 'hasContent', 'query', 'getFromPartition', 'setContent',
|
|
1135
|
+
'deleteContent', 'replace', 'patch'
|
|
1136
|
+
];
|
|
1137
|
+
|
|
1138
|
+
for (const method of methods) {
|
|
1139
|
+
for (const middleware of middlewares) {
|
|
1140
|
+
if (typeof middleware === 'function') {
|
|
1141
|
+
resource.useMiddleware(method, middleware);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return;
|
|
1033
1146
|
}
|
|
1034
1147
|
|
|
1035
|
-
|
|
1148
|
+
// Format 2: Object with method-specific middlewares
|
|
1149
|
+
if (typeof middlewares === 'object' && middlewares !== null) {
|
|
1150
|
+
for (const [method, fns] of Object.entries(middlewares)) {
|
|
1151
|
+
if (method === '*') {
|
|
1152
|
+
// Apply to all methods
|
|
1153
|
+
const methods = resource._middlewareMethods || [
|
|
1154
|
+
'get', 'list', 'listIds', 'getAll', 'count', 'page',
|
|
1155
|
+
'insert', 'update', 'delete', 'deleteMany', 'exists', 'getMany',
|
|
1156
|
+
'content', 'hasContent', 'query', 'getFromPartition', 'setContent',
|
|
1157
|
+
'deleteContent', 'replace', 'patch'
|
|
1158
|
+
];
|
|
1159
|
+
|
|
1160
|
+
const middlewareArray = Array.isArray(fns) ? fns : [fns];
|
|
1161
|
+
for (const targetMethod of methods) {
|
|
1162
|
+
for (const middleware of middlewareArray) {
|
|
1163
|
+
if (typeof middleware === 'function') {
|
|
1164
|
+
resource.useMiddleware(targetMethod, middleware);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
} else {
|
|
1169
|
+
// Apply to specific method
|
|
1170
|
+
const middlewareArray = Array.isArray(fns) ? fns : [fns];
|
|
1171
|
+
for (const middleware of middlewareArray) {
|
|
1172
|
+
if (typeof middleware === 'function') {
|
|
1173
|
+
resource.useMiddleware(method, middleware);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1036
1179
|
}
|
|
1037
1180
|
|
|
1038
1181
|
/**
|
|
@@ -1049,14 +1192,14 @@ export class Database extends EventEmitter {
|
|
|
1049
1192
|
* @returns {Resource} Resource instance
|
|
1050
1193
|
*/
|
|
1051
1194
|
async getResource(name) {
|
|
1052
|
-
if (!this.
|
|
1195
|
+
if (!this._resourcesMap[name]) {
|
|
1053
1196
|
throw new ResourceNotFound({
|
|
1054
1197
|
bucket: this.client.config.bucket,
|
|
1055
1198
|
resourceName: name,
|
|
1056
1199
|
id: name
|
|
1057
1200
|
});
|
|
1058
1201
|
}
|
|
1059
|
-
return this.
|
|
1202
|
+
return this._resourcesMap[name];
|
|
1060
1203
|
}
|
|
1061
1204
|
|
|
1062
1205
|
/**
|
|
@@ -1120,7 +1263,7 @@ export class Database extends EventEmitter {
|
|
|
1120
1263
|
});
|
|
1121
1264
|
}
|
|
1122
1265
|
// Instead of reassigning, clear in place
|
|
1123
|
-
Object.keys(this.resources).forEach(k => delete this.
|
|
1266
|
+
Object.keys(this.resources).forEach(k => delete this._resourcesMap[k]);
|
|
1124
1267
|
}
|
|
1125
1268
|
|
|
1126
1269
|
// 3. Remove all listeners from the client
|
package/src/index.js
CHANGED
|
@@ -13,20 +13,26 @@ export { Validator } from './validator.class.js'
|
|
|
13
13
|
export { ConnectionString } from './connection-string.class.js'
|
|
14
14
|
|
|
15
15
|
// stream classes
|
|
16
|
-
export {
|
|
17
|
-
ResourceReader,
|
|
18
|
-
ResourceWriter,
|
|
19
|
-
ResourceIdsReader,
|
|
16
|
+
export {
|
|
17
|
+
ResourceReader,
|
|
18
|
+
ResourceWriter,
|
|
19
|
+
ResourceIdsReader,
|
|
20
20
|
ResourceIdsPageReader,
|
|
21
21
|
streamToString
|
|
22
22
|
} from './stream/index.js'
|
|
23
23
|
|
|
24
|
+
// typescript generation
|
|
25
|
+
export { generateTypes, printTypes } from './concerns/typescript-generator.js'
|
|
26
|
+
|
|
27
|
+
// testing utilities
|
|
28
|
+
export { Factory, Seeder } from './testing/index.js'
|
|
29
|
+
|
|
24
30
|
// behaviors
|
|
25
|
-
export {
|
|
26
|
-
behaviors,
|
|
27
|
-
getBehavior,
|
|
28
|
-
AVAILABLE_BEHAVIORS,
|
|
29
|
-
DEFAULT_BEHAVIOR
|
|
31
|
+
export {
|
|
32
|
+
behaviors,
|
|
33
|
+
getBehavior,
|
|
34
|
+
AVAILABLE_BEHAVIORS,
|
|
35
|
+
DEFAULT_BEHAVIOR
|
|
30
36
|
} from './behaviors/index.js'
|
|
31
37
|
|
|
32
38
|
// default
|
package/src/plugins/api/index.js
CHANGED
|
@@ -56,7 +56,6 @@ export class ApiPlugin extends Plugin {
|
|
|
56
56
|
host: options.host || '0.0.0.0',
|
|
57
57
|
verbose: options.verbose || false,
|
|
58
58
|
|
|
59
|
-
// API Documentation (supports both new and legacy formats)
|
|
60
59
|
docs: {
|
|
61
60
|
enabled: options.docs?.enabled !== false && options.docsEnabled !== false, // Enable by default
|
|
62
61
|
ui: options.docs?.ui || 'redoc', // 'swagger' or 'redoc' (redoc is prettier!)
|