s3db.js 10.0.18 → 11.0.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/dist/s3db.cjs.js +606 -206
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +198 -2
- package/dist/s3db.es.js +606 -206
- package/dist/s3db.es.js.map +1 -1
- package/package.json +4 -2
- package/src/concerns/plugin-storage.js +443 -0
- package/src/database.class.js +48 -15
- package/src/plugins/audit.plugin.js +1 -1
- package/src/plugins/backup.plugin.js +1 -1
- package/src/plugins/cache.plugin.js +2 -6
- package/src/plugins/eventual-consistency/analytics.js +16 -4
- package/src/plugins/eventual-consistency/consolidation.js +18 -1
- package/src/plugins/eventual-consistency/index.js +4 -4
- package/src/plugins/eventual-consistency/{setup.js → install.js} +7 -6
- package/src/plugins/fulltext.plugin.js +3 -4
- package/src/plugins/metrics.plugin.js +10 -11
- package/src/plugins/plugin.class.js +79 -9
- package/src/plugins/queue-consumer.plugin.js +4 -3
- package/src/plugins/replicator.plugin.js +11 -13
- package/src/plugins/s3-queue.plugin.js +1 -1
- package/src/plugins/scheduler.plugin.js +8 -9
- package/src/plugins/state-machine.plugin.js +3 -4
- package/src/s3db.d.ts +198 -2
- package/src/plugins/eventual-consistency.plugin.js +0 -2559
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s3db.js",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.0",
|
|
4
4
|
"description": "Use AWS S3, the world's most reliable document storage, as a database with this ORM.",
|
|
5
5
|
"main": "dist/s3db.cjs.js",
|
|
6
6
|
"module": "dist/s3db.es.js",
|
|
@@ -118,6 +118,7 @@
|
|
|
118
118
|
"rollup-plugin-polyfill-node": "^0.13.0",
|
|
119
119
|
"rollup-plugin-shebang-bin": "^0.1.0",
|
|
120
120
|
"rollup-plugin-terser": "^7.0.2",
|
|
121
|
+
"tsx": "^4.20.6",
|
|
121
122
|
"typescript": "5.9.3",
|
|
122
123
|
"webpack": "^5.102.1",
|
|
123
124
|
"webpack-cli": "^6.0.1"
|
|
@@ -142,6 +143,7 @@
|
|
|
142
143
|
"release:check": "./scripts/pre-release-check.sh",
|
|
143
144
|
"release:prepare": "pnpm run build:binaries && echo 'Binaries ready for GitHub release'",
|
|
144
145
|
"release": "./scripts/release.sh",
|
|
145
|
-
"validate:types": "pnpm run test:ts && echo 'TypeScript definitions are valid!'"
|
|
146
|
+
"validate:types": "pnpm run test:ts && echo 'TypeScript definitions are valid!'",
|
|
147
|
+
"test:ts:runtime": "tsx tests/typescript/types-runtime-simple.ts"
|
|
146
148
|
}
|
|
147
149
|
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PluginStorage - Lightweight storage utility for plugins
|
|
3
|
+
*
|
|
4
|
+
* Provides efficient S3 storage for plugins without the overhead of full Resources.
|
|
5
|
+
* Reuses metadata encoding/decoding and behaviors for cost optimization.
|
|
6
|
+
*
|
|
7
|
+
* Key Features:
|
|
8
|
+
* - Hierarchical key structure: resource={name}/plugin={slug}/...
|
|
9
|
+
* - Metadata encoding for cost optimization (reuses existing system)
|
|
10
|
+
* - Behavior support: body-overflow, body-only, enforce-limits
|
|
11
|
+
* - Direct Client operations (no Resource overhead)
|
|
12
|
+
* - 3-5x faster than creating Resources
|
|
13
|
+
* - 30-40% fewer S3 API calls
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const storage = new PluginStorage(client, 'eventual-consistency');
|
|
17
|
+
*
|
|
18
|
+
* // Save transaction
|
|
19
|
+
* await storage.put(
|
|
20
|
+
* storage.getPluginKey('wallets', 'balance', 'transactions', 'id=txn1'),
|
|
21
|
+
* { operation: 'add', value: 50 },
|
|
22
|
+
* { behavior: 'body-overflow' }
|
|
23
|
+
* );
|
|
24
|
+
*
|
|
25
|
+
* // Get transaction
|
|
26
|
+
* const txn = await storage.get(
|
|
27
|
+
* storage.getPluginKey('wallets', 'balance', 'transactions', 'id=txn1')
|
|
28
|
+
* );
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { metadataEncode, metadataDecode } from './metadata-encoding.js';
|
|
32
|
+
import { calculateEffectiveLimit, calculateUTF8Bytes } from './calculator.js';
|
|
33
|
+
import { tryFn } from './try-fn.js';
|
|
34
|
+
|
|
35
|
+
const S3_METADATA_LIMIT = 2047; // AWS S3 metadata limit in bytes
|
|
36
|
+
|
|
37
|
+
export class PluginStorage {
|
|
38
|
+
/**
|
|
39
|
+
* @param {Object} client - S3db Client instance
|
|
40
|
+
* @param {string} pluginSlug - Plugin identifier (kebab-case)
|
|
41
|
+
*/
|
|
42
|
+
constructor(client, pluginSlug) {
|
|
43
|
+
if (!client) {
|
|
44
|
+
throw new Error('PluginStorage requires a client instance');
|
|
45
|
+
}
|
|
46
|
+
if (!pluginSlug) {
|
|
47
|
+
throw new Error('PluginStorage requires a pluginSlug');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.client = client;
|
|
51
|
+
this.pluginSlug = pluginSlug;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generate hierarchical plugin-scoped key
|
|
56
|
+
*
|
|
57
|
+
* @param {string} resourceName - Resource name (optional, for resource-scoped data)
|
|
58
|
+
* @param {...string} parts - Additional path parts
|
|
59
|
+
* @returns {string} S3 key
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* // Resource-scoped: resource=wallets/plugin=eventual-consistency/balance/transactions/id=txn1
|
|
63
|
+
* getPluginKey('wallets', 'balance', 'transactions', 'id=txn1')
|
|
64
|
+
*
|
|
65
|
+
* // Global plugin data: plugin=eventual-consistency/config
|
|
66
|
+
* getPluginKey(null, 'config')
|
|
67
|
+
*/
|
|
68
|
+
getPluginKey(resourceName, ...parts) {
|
|
69
|
+
if (resourceName) {
|
|
70
|
+
return `resource=${resourceName}/plugin=${this.pluginSlug}/${parts.join('/')}`;
|
|
71
|
+
}
|
|
72
|
+
return `plugin=${this.pluginSlug}/${parts.join('/')}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Save data with metadata encoding and behavior support
|
|
77
|
+
*
|
|
78
|
+
* @param {string} key - S3 key
|
|
79
|
+
* @param {Object} data - Data to save
|
|
80
|
+
* @param {Object} options - Options
|
|
81
|
+
* @param {string} options.behavior - 'body-overflow' | 'body-only' | 'enforce-limits'
|
|
82
|
+
* @param {string} options.contentType - Content type (default: application/json)
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
*/
|
|
85
|
+
async put(key, data, options = {}) {
|
|
86
|
+
const { behavior = 'body-overflow', contentType = 'application/json' } = options;
|
|
87
|
+
|
|
88
|
+
// Apply behavior to split data between metadata and body
|
|
89
|
+
const { metadata, body } = this._applyBehavior(data, behavior);
|
|
90
|
+
|
|
91
|
+
// Prepare putObject parameters
|
|
92
|
+
const putParams = {
|
|
93
|
+
key,
|
|
94
|
+
metadata,
|
|
95
|
+
contentType
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Add body if present
|
|
99
|
+
if (body !== null) {
|
|
100
|
+
putParams.body = JSON.stringify(body);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Save to S3
|
|
104
|
+
const [ok, err] = await tryFn(() => this.client.putObject(putParams));
|
|
105
|
+
|
|
106
|
+
if (!ok) {
|
|
107
|
+
throw new Error(`PluginStorage.put failed for key ${key}: ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get data with automatic metadata decoding
|
|
113
|
+
*
|
|
114
|
+
* @param {string} key - S3 key
|
|
115
|
+
* @returns {Promise<Object|null>} Data or null if not found
|
|
116
|
+
*/
|
|
117
|
+
async get(key) {
|
|
118
|
+
const [ok, err, response] = await tryFn(() => this.client.getObject(key));
|
|
119
|
+
|
|
120
|
+
if (!ok) {
|
|
121
|
+
// If not found, return null
|
|
122
|
+
if (err.name === 'NoSuchKey' || err.Code === 'NoSuchKey') {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`PluginStorage.get failed for key ${key}: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Metadata is already decoded by Client, but values are strings
|
|
129
|
+
// We need to parse JSON values back to objects
|
|
130
|
+
const metadata = response.Metadata || {};
|
|
131
|
+
const parsedMetadata = this._parseMetadataValues(metadata);
|
|
132
|
+
|
|
133
|
+
// If has body, merge with metadata
|
|
134
|
+
if (response.Body) {
|
|
135
|
+
try {
|
|
136
|
+
const bodyContent = await response.Body.transformToString();
|
|
137
|
+
|
|
138
|
+
// Only parse if body has content
|
|
139
|
+
if (bodyContent && bodyContent.trim()) {
|
|
140
|
+
const body = JSON.parse(bodyContent);
|
|
141
|
+
// Body takes precedence over metadata for same keys
|
|
142
|
+
return { ...parsedMetadata, ...body };
|
|
143
|
+
}
|
|
144
|
+
} catch (parseErr) {
|
|
145
|
+
throw new Error(`PluginStorage.get failed to parse body for key ${key}: ${parseErr.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return parsedMetadata;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse metadata values back to their original types
|
|
154
|
+
* @private
|
|
155
|
+
*/
|
|
156
|
+
_parseMetadataValues(metadata) {
|
|
157
|
+
const parsed = {};
|
|
158
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
159
|
+
// Try to parse as JSON
|
|
160
|
+
if (typeof value === 'string') {
|
|
161
|
+
// Check if it looks like JSON
|
|
162
|
+
if (
|
|
163
|
+
(value.startsWith('{') && value.endsWith('}')) ||
|
|
164
|
+
(value.startsWith('[') && value.endsWith(']'))
|
|
165
|
+
) {
|
|
166
|
+
try {
|
|
167
|
+
parsed[key] = JSON.parse(value);
|
|
168
|
+
continue;
|
|
169
|
+
} catch {
|
|
170
|
+
// Not JSON, keep as string
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Try to parse as number
|
|
175
|
+
if (!isNaN(value) && value.trim() !== '') {
|
|
176
|
+
parsed[key] = Number(value);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Try to parse as boolean
|
|
181
|
+
if (value === 'true') {
|
|
182
|
+
parsed[key] = true;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (value === 'false') {
|
|
186
|
+
parsed[key] = false;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Keep as is
|
|
192
|
+
parsed[key] = value;
|
|
193
|
+
}
|
|
194
|
+
return parsed;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* List all keys with plugin prefix
|
|
199
|
+
*
|
|
200
|
+
* @param {string} prefix - Additional prefix (optional)
|
|
201
|
+
* @param {Object} options - List options
|
|
202
|
+
* @param {number} options.limit - Max number of results
|
|
203
|
+
* @returns {Promise<Array<string>>} List of keys
|
|
204
|
+
*/
|
|
205
|
+
async list(prefix = '', options = {}) {
|
|
206
|
+
const { limit } = options;
|
|
207
|
+
|
|
208
|
+
// Build full prefix
|
|
209
|
+
const fullPrefix = prefix
|
|
210
|
+
? `plugin=${this.pluginSlug}/${prefix}`
|
|
211
|
+
: `plugin=${this.pluginSlug}/`;
|
|
212
|
+
|
|
213
|
+
const [ok, err, result] = await tryFn(() =>
|
|
214
|
+
this.client.listObjects({ prefix: fullPrefix, maxKeys: limit })
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
if (!ok) {
|
|
218
|
+
throw new Error(`PluginStorage.list failed: ${err.message}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Remove keyPrefix from keys
|
|
222
|
+
const keys = result.Contents?.map(item => item.Key) || [];
|
|
223
|
+
return this._removeKeyPrefix(keys);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* List keys for a specific resource
|
|
228
|
+
*
|
|
229
|
+
* @param {string} resourceName - Resource name
|
|
230
|
+
* @param {string} subPrefix - Additional prefix within resource (optional)
|
|
231
|
+
* @param {Object} options - List options
|
|
232
|
+
* @returns {Promise<Array<string>>} List of keys
|
|
233
|
+
*/
|
|
234
|
+
async listForResource(resourceName, subPrefix = '', options = {}) {
|
|
235
|
+
const { limit } = options;
|
|
236
|
+
|
|
237
|
+
// Build resource-scoped prefix
|
|
238
|
+
const fullPrefix = subPrefix
|
|
239
|
+
? `resource=${resourceName}/plugin=${this.pluginSlug}/${subPrefix}`
|
|
240
|
+
: `resource=${resourceName}/plugin=${this.pluginSlug}/`;
|
|
241
|
+
|
|
242
|
+
const [ok, err, result] = await tryFn(() =>
|
|
243
|
+
this.client.listObjects({ prefix: fullPrefix, maxKeys: limit })
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (!ok) {
|
|
247
|
+
throw new Error(`PluginStorage.listForResource failed: ${err.message}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Remove keyPrefix from keys
|
|
251
|
+
const keys = result.Contents?.map(item => item.Key) || [];
|
|
252
|
+
return this._removeKeyPrefix(keys);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Remove client keyPrefix from keys
|
|
257
|
+
* @private
|
|
258
|
+
*/
|
|
259
|
+
_removeKeyPrefix(keys) {
|
|
260
|
+
const keyPrefix = this.client.config.keyPrefix;
|
|
261
|
+
if (!keyPrefix) return keys;
|
|
262
|
+
|
|
263
|
+
return keys
|
|
264
|
+
.map(key => key.replace(keyPrefix, ''))
|
|
265
|
+
.map(key => (key.startsWith('/') ? key.replace('/', '') : key));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Delete a single object
|
|
270
|
+
*
|
|
271
|
+
* @param {string} key - S3 key
|
|
272
|
+
* @returns {Promise<void>}
|
|
273
|
+
*/
|
|
274
|
+
async delete(key) {
|
|
275
|
+
const [ok, err] = await tryFn(() => this.client.deleteObject(key));
|
|
276
|
+
|
|
277
|
+
if (!ok) {
|
|
278
|
+
throw new Error(`PluginStorage.delete failed for key ${key}: ${err.message}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Delete all plugin data (for uninstall)
|
|
284
|
+
*
|
|
285
|
+
* @param {string} resourceName - Resource name (optional, if null deletes all plugin data)
|
|
286
|
+
* @returns {Promise<number>} Number of objects deleted
|
|
287
|
+
*/
|
|
288
|
+
async deleteAll(resourceName = null) {
|
|
289
|
+
let deleted = 0;
|
|
290
|
+
|
|
291
|
+
if (resourceName) {
|
|
292
|
+
// Delete all data for specific resource
|
|
293
|
+
const keys = await this.listForResource(resourceName);
|
|
294
|
+
|
|
295
|
+
for (const key of keys) {
|
|
296
|
+
await this.delete(key);
|
|
297
|
+
deleted++;
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
// Delete ALL plugin data (global + all resource-scoped)
|
|
301
|
+
// We need to list all keys and filter by plugin slug
|
|
302
|
+
const allKeys = await this.client.getAllKeys({});
|
|
303
|
+
|
|
304
|
+
// Filter keys that belong to this plugin
|
|
305
|
+
// Format: plugin=<slug>/* OR resource=*/plugin=<slug>/*
|
|
306
|
+
const pluginKeys = allKeys.filter(key =>
|
|
307
|
+
key.includes(`plugin=${this.pluginSlug}/`)
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
for (const key of pluginKeys) {
|
|
311
|
+
await this.delete(key);
|
|
312
|
+
deleted++;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return deleted;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Batch put operations
|
|
321
|
+
*
|
|
322
|
+
* @param {Array<{key: string, data: Object, options?: Object}>} items - Items to save
|
|
323
|
+
* @returns {Promise<Array<{key: string, ok: boolean, error?: Error}>>} Results
|
|
324
|
+
*/
|
|
325
|
+
async batchPut(items) {
|
|
326
|
+
const results = [];
|
|
327
|
+
|
|
328
|
+
for (const item of items) {
|
|
329
|
+
const [ok, err] = await tryFn(() =>
|
|
330
|
+
this.put(item.key, item.data, item.options)
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
results.push({
|
|
334
|
+
key: item.key,
|
|
335
|
+
ok,
|
|
336
|
+
error: err
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return results;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Batch get operations
|
|
345
|
+
*
|
|
346
|
+
* @param {Array<string>} keys - Keys to fetch
|
|
347
|
+
* @returns {Promise<Array<{key: string, ok: boolean, data?: Object, error?: Error}>>} Results
|
|
348
|
+
*/
|
|
349
|
+
async batchGet(keys) {
|
|
350
|
+
const results = [];
|
|
351
|
+
|
|
352
|
+
for (const key of keys) {
|
|
353
|
+
const [ok, err, data] = await tryFn(() => this.get(key));
|
|
354
|
+
|
|
355
|
+
results.push({
|
|
356
|
+
key,
|
|
357
|
+
ok,
|
|
358
|
+
data,
|
|
359
|
+
error: err
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return results;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Apply behavior to split data between metadata and body
|
|
368
|
+
*
|
|
369
|
+
* @private
|
|
370
|
+
* @param {Object} data - Data to split
|
|
371
|
+
* @param {string} behavior - Behavior strategy
|
|
372
|
+
* @returns {{metadata: Object, body: Object|null}}
|
|
373
|
+
*/
|
|
374
|
+
_applyBehavior(data, behavior) {
|
|
375
|
+
const effectiveLimit = calculateEffectiveLimit({ s3Limit: S3_METADATA_LIMIT });
|
|
376
|
+
let metadata = {};
|
|
377
|
+
let body = null;
|
|
378
|
+
|
|
379
|
+
switch (behavior) {
|
|
380
|
+
case 'body-overflow': {
|
|
381
|
+
// Sort fields by size (smallest first)
|
|
382
|
+
const entries = Object.entries(data);
|
|
383
|
+
const sorted = entries.map(([key, value]) => {
|
|
384
|
+
// JSON-encode objects and arrays for metadata storage
|
|
385
|
+
const jsonValue = typeof value === 'object' ? JSON.stringify(value) : value;
|
|
386
|
+
const { encoded } = metadataEncode(jsonValue);
|
|
387
|
+
const keySize = calculateUTF8Bytes(key);
|
|
388
|
+
const valueSize = calculateUTF8Bytes(encoded);
|
|
389
|
+
return { key, value, jsonValue, encoded, size: keySize + valueSize };
|
|
390
|
+
}).sort((a, b) => a.size - b.size);
|
|
391
|
+
|
|
392
|
+
// Fill metadata first, overflow to body
|
|
393
|
+
let currentSize = 0;
|
|
394
|
+
for (const item of sorted) {
|
|
395
|
+
if (currentSize + item.size <= effectiveLimit) {
|
|
396
|
+
metadata[item.key] = item.jsonValue;
|
|
397
|
+
currentSize += item.size;
|
|
398
|
+
} else {
|
|
399
|
+
if (body === null) body = {};
|
|
400
|
+
body[item.key] = item.value;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
case 'body-only': {
|
|
407
|
+
// Everything goes to body
|
|
408
|
+
body = data;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
case 'enforce-limits': {
|
|
413
|
+
// Try to fit everything in metadata, throw if exceeds
|
|
414
|
+
let currentSize = 0;
|
|
415
|
+
for (const [key, value] of Object.entries(data)) {
|
|
416
|
+
// JSON-encode objects and arrays for metadata storage
|
|
417
|
+
const jsonValue = typeof value === 'object' ? JSON.stringify(value) : value;
|
|
418
|
+
const { encoded } = metadataEncode(jsonValue);
|
|
419
|
+
const keySize = calculateUTF8Bytes(key);
|
|
420
|
+
const valueSize = calculateUTF8Bytes(encoded);
|
|
421
|
+
currentSize += keySize + valueSize;
|
|
422
|
+
|
|
423
|
+
if (currentSize > effectiveLimit) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`Data exceeds metadata limit (${currentSize} > ${effectiveLimit} bytes). ` +
|
|
426
|
+
`Use 'body-overflow' or 'body-only' behavior.`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
metadata[key] = jsonValue;
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
default:
|
|
436
|
+
throw new Error(`Unknown behavior: ${behavior}. Use 'body-overflow', 'body-only', or 'enforce-limits'.`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { metadata, body };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export default PluginStorage;
|
package/src/database.class.js
CHANGED
|
@@ -393,22 +393,18 @@ export class Database extends EventEmitter {
|
|
|
393
393
|
if (!isEmpty(this.pluginList)) {
|
|
394
394
|
const plugins = this.pluginList.map(p => isFunction(p) ? new p(this) : p)
|
|
395
395
|
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// Register the plugin using the same naming convention as usePlugin()
|
|
396
|
+
const installProms = plugins.map(async (plugin) => {
|
|
397
|
+
await plugin.install(db)
|
|
398
|
+
|
|
399
|
+
// Register the plugin
|
|
402
400
|
const pluginName = this._getPluginName(plugin);
|
|
403
401
|
this.pluginRegistry[pluginName] = plugin;
|
|
404
402
|
});
|
|
405
|
-
|
|
406
|
-
await Promise.all(
|
|
403
|
+
|
|
404
|
+
await Promise.all(installProms);
|
|
407
405
|
|
|
408
406
|
const startProms = plugins.map(async (plugin) => {
|
|
409
|
-
if (plugin.beforeStart) await plugin.beforeStart()
|
|
410
407
|
await plugin.start()
|
|
411
|
-
if (plugin.afterStart) await plugin.afterStart()
|
|
412
408
|
});
|
|
413
409
|
|
|
414
410
|
await Promise.all(startProms);
|
|
@@ -430,19 +426,56 @@ export class Database extends EventEmitter {
|
|
|
430
426
|
|
|
431
427
|
async usePlugin(plugin, name = null) {
|
|
432
428
|
const pluginName = this._getPluginName(plugin, name);
|
|
433
|
-
|
|
429
|
+
|
|
434
430
|
// Register the plugin
|
|
435
431
|
this.plugins[pluginName] = plugin;
|
|
436
|
-
|
|
437
|
-
//
|
|
432
|
+
|
|
433
|
+
// Install the plugin if database is connected
|
|
438
434
|
if (this.isConnected()) {
|
|
439
|
-
await plugin.
|
|
435
|
+
await plugin.install(this);
|
|
440
436
|
await plugin.start();
|
|
441
437
|
}
|
|
442
|
-
|
|
438
|
+
|
|
443
439
|
return plugin;
|
|
444
440
|
}
|
|
445
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Uninstall a plugin and optionally purge its data
|
|
444
|
+
* @param {string} name - Plugin name
|
|
445
|
+
* @param {Object} options - Uninstall options
|
|
446
|
+
* @param {boolean} options.purgeData - Delete all plugin data from S3 (default: false)
|
|
447
|
+
*/
|
|
448
|
+
async uninstallPlugin(name, options = {}) {
|
|
449
|
+
const pluginName = name.toLowerCase().replace('plugin', '');
|
|
450
|
+
const plugin = this.plugins[pluginName] || this.pluginRegistry[pluginName];
|
|
451
|
+
|
|
452
|
+
if (!plugin) {
|
|
453
|
+
throw new Error(`Plugin '${name}' not found`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Stop the plugin first
|
|
457
|
+
if (plugin.stop) {
|
|
458
|
+
await plugin.stop();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Uninstall the plugin
|
|
462
|
+
if (plugin.uninstall) {
|
|
463
|
+
await plugin.uninstall(options);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Remove from registries
|
|
467
|
+
delete this.plugins[pluginName];
|
|
468
|
+
delete this.pluginRegistry[pluginName];
|
|
469
|
+
|
|
470
|
+
// Remove from plugin list
|
|
471
|
+
const index = this.pluginList.indexOf(plugin);
|
|
472
|
+
if (index > -1) {
|
|
473
|
+
this.pluginList.splice(index, 1);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
this.emit('plugin.uninstalled', { name: pluginName, plugin });
|
|
477
|
+
}
|
|
478
|
+
|
|
446
479
|
async uploadMetadataFile() {
|
|
447
480
|
const metadata = {
|
|
448
481
|
version: this.version,
|
|
@@ -132,7 +132,7 @@ export class BackupPlugin extends Plugin {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
async
|
|
135
|
+
async onInstall() {
|
|
136
136
|
// Create backup driver instance
|
|
137
137
|
this.driver = createBackupDriver(this.config.driver, this.config.driverConfig);
|
|
138
138
|
await this.driver.setup(this.database);
|
|
@@ -43,11 +43,7 @@ export class CachePlugin extends Plugin {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
async
|
|
47
|
-
await super.setup(database);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async onSetup() {
|
|
46
|
+
async onInstall() {
|
|
51
47
|
// Initialize cache driver
|
|
52
48
|
if (this.config.driver && typeof this.config.driver === 'object') {
|
|
53
49
|
// Use custom driver instance if provided
|
|
@@ -76,7 +72,7 @@ export class CachePlugin extends Plugin {
|
|
|
76
72
|
|
|
77
73
|
// Use database hooks instead of method overwriting
|
|
78
74
|
this.installDatabaseHooks();
|
|
79
|
-
|
|
75
|
+
|
|
80
76
|
// Install hooks for existing resources
|
|
81
77
|
this.installResourceHooks();
|
|
82
78
|
}
|
|
@@ -64,10 +64,20 @@ export async function updateAnalytics(transactions, analyticsResource, config) {
|
|
|
64
64
|
);
|
|
65
65
|
}
|
|
66
66
|
} catch (error) {
|
|
67
|
-
console.
|
|
68
|
-
`[EventualConsistency] ${config.resource}.${config.field} - ` +
|
|
69
|
-
`Analytics update
|
|
70
|
-
|
|
67
|
+
console.error(
|
|
68
|
+
`[EventualConsistency] CRITICAL: ${config.resource}.${config.field} - ` +
|
|
69
|
+
`Analytics update failed:`,
|
|
70
|
+
{
|
|
71
|
+
error: error.message,
|
|
72
|
+
stack: error.stack,
|
|
73
|
+
field: config.field,
|
|
74
|
+
resource: config.resource,
|
|
75
|
+
transactionCount: transactions.length
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
// Re-throw to prevent silent failures
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Analytics update failed for ${config.resource}.${config.field}: ${error.message}`
|
|
71
81
|
);
|
|
72
82
|
}
|
|
73
83
|
}
|
|
@@ -144,6 +154,7 @@ async function upsertAnalytics(period, cohort, transactions, analyticsResource,
|
|
|
144
154
|
await tryFn(() =>
|
|
145
155
|
analyticsResource.insert({
|
|
146
156
|
id,
|
|
157
|
+
field: config.field,
|
|
147
158
|
period,
|
|
148
159
|
cohort,
|
|
149
160
|
transactionCount,
|
|
@@ -266,6 +277,7 @@ async function rollupPeriod(period, cohort, sourcePrefix, analyticsResource, con
|
|
|
266
277
|
await tryFn(() =>
|
|
267
278
|
analyticsResource.insert({
|
|
268
279
|
id,
|
|
280
|
+
field: config.field,
|
|
269
281
|
period,
|
|
270
282
|
cohort,
|
|
271
283
|
transactionCount,
|
|
@@ -475,7 +475,24 @@ export async function consolidateRecord(
|
|
|
475
475
|
|
|
476
476
|
// Update analytics if enabled (only for real transactions, not synthetic)
|
|
477
477
|
if (config.enableAnalytics && transactionsToUpdate.length > 0 && updateAnalyticsFn) {
|
|
478
|
-
await
|
|
478
|
+
const [analyticsOk, analyticsErr] = await tryFn(() =>
|
|
479
|
+
updateAnalyticsFn(transactionsToUpdate)
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
if (!analyticsOk) {
|
|
483
|
+
// Analytics failure should NOT prevent consolidation success
|
|
484
|
+
// But we should log it prominently
|
|
485
|
+
console.error(
|
|
486
|
+
`[EventualConsistency] ${config.resource}.${config.field} - ` +
|
|
487
|
+
`CRITICAL: Analytics update failed for ${originalId}, but consolidation succeeded:`,
|
|
488
|
+
{
|
|
489
|
+
error: analyticsErr?.message || analyticsErr,
|
|
490
|
+
stack: analyticsErr?.stack,
|
|
491
|
+
originalId,
|
|
492
|
+
transactionCount: transactionsToUpdate.length
|
|
493
|
+
}
|
|
494
|
+
);
|
|
495
|
+
}
|
|
479
496
|
}
|
|
480
497
|
|
|
481
498
|
// Invalidate cache for this record after consolidation
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "./consolidation.js";
|
|
19
19
|
import { runGarbageCollection } from "./garbage-collection.js";
|
|
20
20
|
import { updateAnalytics, getAnalytics, getMonthByDay, getDayByHour, getLastNDays, getYearByMonth, getMonthByHour, getTopRecords } from "./analytics.js";
|
|
21
|
-
import {
|
|
21
|
+
import { onInstall, onStart, onStop, watchForResource, completeFieldSetup } from "./install.js";
|
|
22
22
|
|
|
23
23
|
export class EventualConsistencyPlugin extends Plugin {
|
|
24
24
|
constructor(options = {}) {
|
|
@@ -53,10 +53,10 @@ export class EventualConsistencyPlugin extends Plugin {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
56
|
+
* Install hook - create resources and register helpers
|
|
57
57
|
*/
|
|
58
|
-
async
|
|
59
|
-
await
|
|
58
|
+
async onInstall() {
|
|
59
|
+
await onInstall(
|
|
60
60
|
this.database,
|
|
61
61
|
this.fieldHandlers,
|
|
62
62
|
(handler) => completeFieldSetup(handler, this.database, this.config, this),
|