s3db.js 6.2.0 → 7.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.
Files changed (60) hide show
  1. package/PLUGINS.md +2724 -0
  2. package/README.md +372 -469
  3. package/UNLICENSE +24 -0
  4. package/dist/s3db.cjs.js +30057 -18387
  5. package/dist/s3db.cjs.min.js +1 -1
  6. package/dist/s3db.d.ts +373 -72
  7. package/dist/s3db.es.js +30043 -18384
  8. package/dist/s3db.es.min.js +1 -1
  9. package/dist/s3db.iife.js +29730 -18061
  10. package/dist/s3db.iife.min.js +1 -1
  11. package/package.json +44 -69
  12. package/src/behaviors/body-only.js +110 -0
  13. package/src/behaviors/body-overflow.js +153 -0
  14. package/src/behaviors/enforce-limits.js +195 -0
  15. package/src/behaviors/index.js +39 -0
  16. package/src/behaviors/truncate-data.js +204 -0
  17. package/src/behaviors/user-managed.js +147 -0
  18. package/src/client.class.js +515 -0
  19. package/src/concerns/base62.js +61 -0
  20. package/src/concerns/calculator.js +204 -0
  21. package/src/concerns/crypto.js +142 -0
  22. package/src/concerns/id.js +8 -0
  23. package/src/concerns/index.js +5 -0
  24. package/src/concerns/try-fn.js +151 -0
  25. package/src/connection-string.class.js +75 -0
  26. package/src/database.class.js +599 -0
  27. package/src/errors.js +261 -0
  28. package/src/index.js +17 -0
  29. package/src/plugins/audit.plugin.js +442 -0
  30. package/src/plugins/cache/cache.class.js +53 -0
  31. package/src/plugins/cache/index.js +6 -0
  32. package/src/plugins/cache/memory-cache.class.js +164 -0
  33. package/src/plugins/cache/s3-cache.class.js +189 -0
  34. package/src/plugins/cache.plugin.js +275 -0
  35. package/src/plugins/consumers/index.js +24 -0
  36. package/src/plugins/consumers/rabbitmq-consumer.js +56 -0
  37. package/src/plugins/consumers/sqs-consumer.js +102 -0
  38. package/src/plugins/costs.plugin.js +81 -0
  39. package/src/plugins/fulltext.plugin.js +473 -0
  40. package/src/plugins/index.js +12 -0
  41. package/src/plugins/metrics.plugin.js +603 -0
  42. package/src/plugins/plugin.class.js +210 -0
  43. package/src/plugins/plugin.obj.js +13 -0
  44. package/src/plugins/queue-consumer.plugin.js +134 -0
  45. package/src/plugins/replicator.plugin.js +769 -0
  46. package/src/plugins/replicators/base-replicator.class.js +85 -0
  47. package/src/plugins/replicators/bigquery-replicator.class.js +328 -0
  48. package/src/plugins/replicators/index.js +44 -0
  49. package/src/plugins/replicators/postgres-replicator.class.js +427 -0
  50. package/src/plugins/replicators/s3db-replicator.class.js +352 -0
  51. package/src/plugins/replicators/sqs-replicator.class.js +427 -0
  52. package/src/resource.class.js +2626 -0
  53. package/src/s3db.d.ts +1263 -0
  54. package/src/schema.class.js +706 -0
  55. package/src/stream/index.js +16 -0
  56. package/src/stream/resource-ids-page-reader.class.js +10 -0
  57. package/src/stream/resource-ids-reader.class.js +63 -0
  58. package/src/stream/resource-reader.class.js +81 -0
  59. package/src/stream/resource-writer.class.js +92 -0
  60. package/src/validator.class.js +97 -0
@@ -0,0 +1,81 @@
1
+ export const CostsPlugin = {
2
+ async setup (db) {
3
+ if (!db || !db.client) {
4
+ return; // Handle null/invalid database gracefully
5
+ }
6
+
7
+ this.client = db.client
8
+
9
+ this.map = {
10
+ PutObjectCommand: 'put',
11
+ GetObjectCommand: 'get',
12
+ HeadObjectCommand: 'head',
13
+ DeleteObjectCommand: 'delete',
14
+ DeleteObjectsCommand: 'delete',
15
+ ListObjectsV2Command: 'list',
16
+ }
17
+
18
+ this.costs = {
19
+ total: 0,
20
+ prices: {
21
+ put: 0.005 / 1000,
22
+ copy: 0.005 / 1000,
23
+ list: 0.005 / 1000,
24
+ post: 0.005 / 1000,
25
+ get: 0.0004 / 1000,
26
+ select: 0.0004 / 1000,
27
+ delete: 0.0004 / 1000,
28
+ head: 0.0004 / 1000,
29
+ },
30
+ requests: {
31
+ total: 0,
32
+ put: 0,
33
+ post: 0,
34
+ copy: 0,
35
+ list: 0,
36
+ get: 0,
37
+ select: 0,
38
+ delete: 0,
39
+ head: 0,
40
+ },
41
+ events: {
42
+ total: 0,
43
+ PutObjectCommand: 0,
44
+ GetObjectCommand: 0,
45
+ HeadObjectCommand: 0,
46
+ DeleteObjectCommand: 0,
47
+ DeleteObjectsCommand: 0,
48
+ ListObjectsV2Command: 0,
49
+ }
50
+ }
51
+
52
+ this.client.costs = JSON.parse(JSON.stringify(this.costs));
53
+ },
54
+
55
+ async start () {
56
+ if (this.client) {
57
+ this.client.on("command.response", (name) => this.addRequest(name, this.map[name]));
58
+ this.client.on("command.error", (name) => this.addRequest(name, this.map[name]));
59
+ }
60
+ },
61
+
62
+ addRequest (name, method) {
63
+ if (!method) return; // Skip if no mapping found
64
+
65
+ this.costs.events[name]++;
66
+ this.costs.events.total++;
67
+ this.costs.requests.total++;
68
+ this.costs.requests[method]++;
69
+ this.costs.total += this.costs.prices[method];
70
+
71
+ if (this.client && this.client.costs) {
72
+ this.client.costs.events[name]++;
73
+ this.client.costs.events.total++;
74
+ this.client.costs.requests.total++;
75
+ this.client.costs.requests[method]++;
76
+ this.client.costs.total += this.client.costs.prices[method];
77
+ }
78
+ },
79
+ }
80
+
81
+ export default CostsPlugin
@@ -0,0 +1,473 @@
1
+ import Plugin from "./plugin.class.js";
2
+ import tryFn from "../concerns/try-fn.js";
3
+
4
+ export class FullTextPlugin extends Plugin {
5
+ constructor(options = {}) {
6
+ super();
7
+ this.indexResource = null;
8
+ this.config = {
9
+ minWordLength: options.minWordLength || 3,
10
+ maxResults: options.maxResults || 100,
11
+ ...options
12
+ };
13
+ this.indexes = new Map(); // In-memory index for simplicity
14
+ }
15
+
16
+ async setup(database) {
17
+ this.database = database;
18
+
19
+ // Create index resource if it doesn't exist
20
+ const [ok, err, indexResource] = await tryFn(() => database.createResource({
21
+ name: 'fulltext_indexes',
22
+ attributes: {
23
+ id: 'string|required',
24
+ resourceName: 'string|required',
25
+ fieldName: 'string|required',
26
+ word: 'string|required',
27
+ recordIds: 'json|required', // Array of record IDs containing this word
28
+ count: 'number|required',
29
+ lastUpdated: 'string|required'
30
+ }
31
+ }));
32
+ this.indexResource = ok ? indexResource : database.resources.fulltext_indexes;
33
+
34
+ // Load existing indexes
35
+ await this.loadIndexes();
36
+
37
+ this.installIndexingHooks();
38
+ }
39
+
40
+ async start() {
41
+ // Plugin is ready
42
+ }
43
+
44
+ async stop() {
45
+ // Save indexes before stopping
46
+ await this.saveIndexes();
47
+ }
48
+
49
+ async loadIndexes() {
50
+ if (!this.indexResource) return;
51
+
52
+ const [ok, err, allIndexes] = await tryFn(() => this.indexResource.getAll());
53
+ if (ok) {
54
+ for (const indexRecord of allIndexes) {
55
+ const key = `${indexRecord.resourceName}:${indexRecord.fieldName}:${indexRecord.word}`;
56
+ this.indexes.set(key, {
57
+ recordIds: indexRecord.recordIds || [],
58
+ count: indexRecord.count || 0
59
+ });
60
+ }
61
+ }
62
+ }
63
+
64
+ async saveIndexes() {
65
+ if (!this.indexResource) return;
66
+
67
+ const [ok, err] = await tryFn(async () => {
68
+ // Clear existing indexes
69
+ const existingIndexes = await this.indexResource.getAll();
70
+ for (const index of existingIndexes) {
71
+ await this.indexResource.delete(index.id);
72
+ }
73
+ // Save current indexes
74
+ for (const [key, data] of this.indexes.entries()) {
75
+ const [resourceName, fieldName, word] = key.split(':');
76
+ await this.indexResource.insert({
77
+ id: `index-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
78
+ resourceName,
79
+ fieldName,
80
+ word,
81
+ recordIds: data.recordIds,
82
+ count: data.count,
83
+ lastUpdated: new Date().toISOString()
84
+ });
85
+ }
86
+ });
87
+ }
88
+
89
+ installIndexingHooks() {
90
+ // Register plugin with database
91
+ if (!this.database.plugins) {
92
+ this.database.plugins = {};
93
+ }
94
+ this.database.plugins.fulltext = this;
95
+
96
+ for (const resource of Object.values(this.database.resources)) {
97
+ if (resource.name === 'fulltext_indexes') continue;
98
+
99
+ this.installResourceHooks(resource);
100
+ }
101
+
102
+ // Hook into database proxy for new resources (check if already installed)
103
+ if (!this.database._fulltextProxyInstalled) {
104
+ // Store the previous createResource (could be another plugin's proxy)
105
+ this.database._previousCreateResourceForFullText = this.database.createResource;
106
+ this.database.createResource = async function (...args) {
107
+ const resource = await this._previousCreateResourceForFullText(...args);
108
+ if (this.plugins?.fulltext && resource.name !== 'fulltext_indexes') {
109
+ this.plugins.fulltext.installResourceHooks(resource);
110
+ }
111
+ return resource;
112
+ };
113
+ this.database._fulltextProxyInstalled = true;
114
+ }
115
+
116
+ // Ensure all existing resources have hooks (even if created before plugin setup)
117
+ for (const resource of Object.values(this.database.resources)) {
118
+ if (resource.name !== 'fulltext_indexes') {
119
+ this.installResourceHooks(resource);
120
+ }
121
+ }
122
+ }
123
+
124
+ installResourceHooks(resource) {
125
+ // Store original methods
126
+ resource._insert = resource.insert;
127
+ resource._update = resource.update;
128
+ resource._delete = resource.delete;
129
+ resource._deleteMany = resource.deleteMany;
130
+
131
+ // Use wrapResourceMethod for all hooks so _pluginWrappers is set
132
+ this.wrapResourceMethod(resource, 'insert', async (result, args, methodName) => {
133
+ const [data] = args;
134
+ // Index the new record
135
+ this.indexRecord(resource.name, result.id, data).catch(console.error);
136
+ return result;
137
+ });
138
+
139
+ this.wrapResourceMethod(resource, 'update', async (result, args, methodName) => {
140
+ const [id, data] = args;
141
+ // Remove old index entries
142
+ this.removeRecordFromIndex(resource.name, id).catch(console.error);
143
+ // Index the updated record
144
+ this.indexRecord(resource.name, id, result).catch(console.error);
145
+ return result;
146
+ });
147
+
148
+ this.wrapResourceMethod(resource, 'delete', async (result, args, methodName) => {
149
+ const [id] = args;
150
+ // Remove from index
151
+ this.removeRecordFromIndex(resource.name, id).catch(console.error);
152
+ return result;
153
+ });
154
+
155
+ this.wrapResourceMethod(resource, 'deleteMany', async (result, args, methodName) => {
156
+ const [ids] = args;
157
+ // Remove from index
158
+ for (const id of ids) {
159
+ this.removeRecordFromIndex(resource.name, id).catch(console.error);
160
+ }
161
+ return result;
162
+ });
163
+ }
164
+
165
+ async indexRecord(resourceName, recordId, data) {
166
+ const indexedFields = this.getIndexedFields(resourceName);
167
+ if (!indexedFields || indexedFields.length === 0) {
168
+ return;
169
+ }
170
+
171
+ for (const fieldName of indexedFields) {
172
+ const fieldValue = this.getFieldValue(data, fieldName);
173
+ if (!fieldValue) {
174
+ continue;
175
+ }
176
+
177
+ const words = this.tokenize(fieldValue);
178
+
179
+ for (const word of words) {
180
+ if (word.length < this.config.minWordLength) {
181
+ continue;
182
+ }
183
+
184
+ const key = `${resourceName}:${fieldName}:${word.toLowerCase()}`;
185
+ const existing = this.indexes.get(key) || { recordIds: [], count: 0 };
186
+
187
+ if (!existing.recordIds.includes(recordId)) {
188
+ existing.recordIds.push(recordId);
189
+ existing.count = existing.recordIds.length;
190
+ }
191
+
192
+ this.indexes.set(key, existing);
193
+ }
194
+ }
195
+ }
196
+
197
+ async removeRecordFromIndex(resourceName, recordId) {
198
+ for (const [key, data] of this.indexes.entries()) {
199
+ if (key.startsWith(`${resourceName}:`)) {
200
+ const index = data.recordIds.indexOf(recordId);
201
+ if (index > -1) {
202
+ data.recordIds.splice(index, 1);
203
+ data.count = data.recordIds.length;
204
+
205
+ if (data.recordIds.length === 0) {
206
+ this.indexes.delete(key);
207
+ } else {
208
+ this.indexes.set(key, data);
209
+ }
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ getFieldValue(data, fieldPath) {
216
+ if (!fieldPath.includes('.')) {
217
+ return data && data[fieldPath] !== undefined ? data[fieldPath] : null;
218
+ }
219
+
220
+ const keys = fieldPath.split('.');
221
+ let value = data;
222
+
223
+ for (const key of keys) {
224
+ if (value && typeof value === 'object' && key in value) {
225
+ value = value[key];
226
+ } else {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ return value;
232
+ }
233
+
234
+ tokenize(text) {
235
+ if (!text) return [];
236
+
237
+ // Convert to string and normalize
238
+ const str = String(text).toLowerCase();
239
+
240
+ // Remove special characters but preserve accented characters
241
+ return str
242
+ .replace(/[^\w\s\u00C0-\u017F]/g, ' ') // Allow accented characters
243
+ .split(/\s+/)
244
+ .filter(word => word.length > 0);
245
+ }
246
+
247
+ getIndexedFields(resourceName) {
248
+ // Use configured fields if available, otherwise fall back to defaults
249
+ if (this.config.fields) {
250
+ return this.config.fields;
251
+ }
252
+
253
+ // Default field mappings
254
+ const fieldMappings = {
255
+ users: ['name', 'email'],
256
+ products: ['name', 'description'],
257
+ articles: ['title', 'content'],
258
+ // Add more mappings as needed
259
+ };
260
+
261
+ return fieldMappings[resourceName] || [];
262
+ }
263
+
264
+ // Main search method
265
+ async search(resourceName, query, options = {}) {
266
+ const {
267
+ fields = null, // Specific fields to search in
268
+ limit = this.config.maxResults,
269
+ offset = 0,
270
+ exactMatch = false
271
+ } = options;
272
+
273
+ if (!query || query.trim().length === 0) {
274
+ return [];
275
+ }
276
+
277
+ const searchWords = this.tokenize(query);
278
+ const results = new Map(); // recordId -> score
279
+
280
+ // Get fields to search in
281
+ const searchFields = fields || this.getIndexedFields(resourceName);
282
+ if (searchFields.length === 0) {
283
+ return [];
284
+ }
285
+
286
+ // Search for each word
287
+ for (const word of searchWords) {
288
+ if (word.length < this.config.minWordLength) continue;
289
+
290
+ for (const fieldName of searchFields) {
291
+ if (exactMatch) {
292
+ // Exact match - look for the exact word
293
+ const key = `${resourceName}:${fieldName}:${word.toLowerCase()}`;
294
+ const indexData = this.indexes.get(key);
295
+
296
+ if (indexData) {
297
+ for (const recordId of indexData.recordIds) {
298
+ const currentScore = results.get(recordId) || 0;
299
+ results.set(recordId, currentScore + 1);
300
+ }
301
+ }
302
+ } else {
303
+ // Partial match - look for words that start with the search term
304
+ for (const [key, indexData] of this.indexes.entries()) {
305
+ if (key.startsWith(`${resourceName}:${fieldName}:${word.toLowerCase()}`)) {
306
+ for (const recordId of indexData.recordIds) {
307
+ const currentScore = results.get(recordId) || 0;
308
+ results.set(recordId, currentScore + 1);
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ // Convert to sorted results
317
+ const sortedResults = Array.from(results.entries())
318
+ .map(([recordId, score]) => ({ recordId, score }))
319
+ .sort((a, b) => b.score - a.score)
320
+ .slice(offset, offset + limit);
321
+
322
+ return sortedResults;
323
+ }
324
+
325
+ // Search and return full records
326
+ async searchRecords(resourceName, query, options = {}) {
327
+ const searchResults = await this.search(resourceName, query, options);
328
+
329
+ if (searchResults.length === 0) {
330
+ return [];
331
+ }
332
+
333
+ const resource = this.database.resources[resourceName];
334
+ if (!resource) {
335
+ throw new Error(`Resource '${resourceName}' not found`);
336
+ }
337
+
338
+ const recordIds = searchResults.map(result => result.recordId);
339
+ const records = await resource.getMany(recordIds);
340
+
341
+ // Filter out undefined/null records (in case getMany returns missing records)
342
+ const result = records
343
+ .filter(record => record && typeof record === 'object')
344
+ .map(record => {
345
+ const searchResult = searchResults.find(sr => sr.recordId === record.id);
346
+ return {
347
+ ...record,
348
+ _searchScore: searchResult ? searchResult.score : 0
349
+ };
350
+ })
351
+ .sort((a, b) => b._searchScore - a._searchScore);
352
+ return result;
353
+ }
354
+
355
+ // Utility methods
356
+ async rebuildIndex(resourceName) {
357
+ const resource = this.database.resources[resourceName];
358
+ if (!resource) {
359
+ throw new Error(`Resource '${resourceName}' not found`);
360
+ }
361
+
362
+ // Clear existing indexes for this resource
363
+ for (const [key] of this.indexes.entries()) {
364
+ if (key.startsWith(`${resourceName}:`)) {
365
+ this.indexes.delete(key);
366
+ }
367
+ }
368
+
369
+ // Rebuild index in larger batches for better performance
370
+ const allRecords = await resource.getAll();
371
+ const batchSize = 100; // Increased batch size for faster processing
372
+
373
+ for (let i = 0; i < allRecords.length; i += batchSize) {
374
+ const batch = allRecords.slice(i, i + batchSize);
375
+ // Process batch sequentially to avoid overwhelming the system
376
+ for (const record of batch) {
377
+ const [ok, err] = await tryFn(() => this.indexRecord(resourceName, record.id, record));
378
+ if (!ok) {
379
+ }
380
+ }
381
+ }
382
+
383
+ // Save indexes
384
+ await this.saveIndexes();
385
+ }
386
+
387
+ async getIndexStats() {
388
+ const stats = {
389
+ totalIndexes: this.indexes.size,
390
+ resources: {},
391
+ totalWords: 0
392
+ };
393
+
394
+ for (const [key, data] of this.indexes.entries()) {
395
+ const [resourceName, fieldName] = key.split(':');
396
+
397
+ if (!stats.resources[resourceName]) {
398
+ stats.resources[resourceName] = {
399
+ fields: {},
400
+ totalRecords: new Set(),
401
+ totalWords: 0
402
+ };
403
+ }
404
+
405
+ if (!stats.resources[resourceName].fields[fieldName]) {
406
+ stats.resources[resourceName].fields[fieldName] = {
407
+ words: 0,
408
+ totalOccurrences: 0
409
+ };
410
+ }
411
+
412
+ stats.resources[resourceName].fields[fieldName].words++;
413
+ stats.resources[resourceName].fields[fieldName].totalOccurrences += data.count;
414
+ stats.resources[resourceName].totalWords++;
415
+
416
+ for (const recordId of data.recordIds) {
417
+ stats.resources[resourceName].totalRecords.add(recordId);
418
+ }
419
+
420
+ stats.totalWords++;
421
+ }
422
+
423
+ // Convert Sets to counts
424
+ for (const resourceName in stats.resources) {
425
+ stats.resources[resourceName].totalRecords = stats.resources[resourceName].totalRecords.size;
426
+ }
427
+
428
+ return stats;
429
+ }
430
+
431
+ async rebuildAllIndexes({ timeout } = {}) {
432
+ if (timeout) {
433
+ return Promise.race([
434
+ this._rebuildAllIndexesInternal(),
435
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout))
436
+ ]);
437
+ }
438
+ return this._rebuildAllIndexesInternal();
439
+ }
440
+
441
+ async _rebuildAllIndexesInternal() {
442
+ const resourceNames = Object.keys(this.database.resources).filter(name => name !== 'fulltext_indexes');
443
+
444
+ // Process resources sequentially to avoid overwhelming the system
445
+ for (const resourceName of resourceNames) {
446
+ const [ok, err] = await tryFn(() => this.rebuildIndex(resourceName));
447
+ if (!ok) {
448
+ }
449
+ }
450
+ }
451
+
452
+ async clearIndex(resourceName) {
453
+ // Clear indexes for specific resource
454
+ for (const [key] of this.indexes.entries()) {
455
+ if (key.startsWith(`${resourceName}:`)) {
456
+ this.indexes.delete(key);
457
+ }
458
+ }
459
+
460
+ // Save changes
461
+ await this.saveIndexes();
462
+ }
463
+
464
+ async clearAllIndexes() {
465
+ // Clear all indexes
466
+ this.indexes.clear();
467
+
468
+ // Save changes
469
+ await this.saveIndexes();
470
+ }
471
+ }
472
+
473
+ export default FullTextPlugin;
@@ -0,0 +1,12 @@
1
+ export * from './plugin.class.js'
2
+ export * from './plugin.obj.js'
3
+ export { default as Plugin } from './plugin.class.js'
4
+
5
+ // plugins:
6
+ export * from './audit.plugin.js'
7
+ export * from './cache.plugin.js'
8
+ export * from './costs.plugin.js'
9
+ export * from './fulltext.plugin.js'
10
+ export * from './metrics.plugin.js'
11
+ export * from './queue-consumer.plugin.js'
12
+ export * from './replicator.plugin.js'