@edge-base/core 0.1.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 (53) hide show
  1. package/dist/context.d.ts +36 -0
  2. package/dist/context.d.ts.map +1 -0
  3. package/dist/context.js +60 -0
  4. package/dist/context.js.map +1 -0
  5. package/dist/errors.d.ts +16 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +26 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/field-ops.d.ts +43 -0
  10. package/dist/field-ops.d.ts.map +1 -0
  11. package/dist/field-ops.js +61 -0
  12. package/dist/field-ops.js.map +1 -0
  13. package/dist/functions.d.ts +50 -0
  14. package/dist/functions.d.ts.map +1 -0
  15. package/dist/functions.js +56 -0
  16. package/dist/functions.js.map +1 -0
  17. package/dist/generated/api-core.d.ts +503 -0
  18. package/dist/generated/api-core.d.ts.map +1 -0
  19. package/dist/generated/api-core.js +496 -0
  20. package/dist/generated/api-core.js.map +1 -0
  21. package/dist/generated/client-wrappers.d.ts +120 -0
  22. package/dist/generated/client-wrappers.d.ts.map +1 -0
  23. package/dist/generated/client-wrappers.js +219 -0
  24. package/dist/generated/client-wrappers.js.map +1 -0
  25. package/dist/http.d.ts +57 -0
  26. package/dist/http.d.ts.map +1 -0
  27. package/dist/http.js +198 -0
  28. package/dist/http.js.map +1 -0
  29. package/dist/index.d.ts +19 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +28 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/mime.d.ts +15 -0
  34. package/dist/mime.d.ts.map +1 -0
  35. package/dist/mime.js +84 -0
  36. package/dist/mime.js.map +1 -0
  37. package/dist/storage.d.ts +246 -0
  38. package/dist/storage.d.ts.map +1 -0
  39. package/dist/storage.js +460 -0
  40. package/dist/storage.js.map +1 -0
  41. package/dist/table.d.ts +322 -0
  42. package/dist/table.d.ts.map +1 -0
  43. package/dist/table.js +734 -0
  44. package/dist/table.js.map +1 -0
  45. package/dist/transport-adapter.d.ts +37 -0
  46. package/dist/transport-adapter.d.ts.map +1 -0
  47. package/dist/transport-adapter.js +70 -0
  48. package/dist/transport-adapter.js.map +1 -0
  49. package/dist/types.d.ts +39 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +9 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +49 -0
package/dist/table.js ADDED
@@ -0,0 +1,734 @@
1
+ /**
2
+ * Collection client with query builder and CRUD operations
3
+ *: filter tuple format
4
+ *: OR filter (.or() chaining)
5
+ *: increment, deleteField
6
+ *: upsert
7
+ *: count
8
+ *: batch-by-filter
9
+ *: batch operations
10
+ * #136: /api/db/{namespace}/tables/{name} URL scheme
11
+ *
12
+ * All HTTP calls delegate to Generated Core (api-core.ts).
13
+ * No hardcoded API paths — the core is the single source of truth.
14
+ */
15
+ import { ApiPaths } from './generated/api-core.js';
16
+ import { serializeFieldOps } from './field-ops.js';
17
+ import { EdgeBaseError } from './errors.js';
18
+ // ─── Database Live Channel Builder ───
19
+ /**
20
+ * Build the database-live channel for a DB table. (§10)
21
+ * Static DB: dblive:shared:posts
22
+ * Dynamic DB: dblive:workspace:ws-456:documents
23
+ * Single doc: dblive:shared:posts:{docId}
24
+ */
25
+ function buildDatabaseLiveChannel(namespace, instanceId, tableName, docId) {
26
+ const base = instanceId
27
+ ? `dblive:${namespace}:${instanceId}:${tableName}`
28
+ : `dblive:${namespace}:${tableName}`;
29
+ return docId ? `${base}:${docId}` : base;
30
+ }
31
+ function evaluateFilterCondition(fieldValue, operator, expected) {
32
+ switch (operator) {
33
+ case '==':
34
+ return fieldValue === expected;
35
+ case '!=':
36
+ return fieldValue !== expected;
37
+ case '<':
38
+ return fieldValue < expected;
39
+ case '>':
40
+ return fieldValue > expected;
41
+ case '<=':
42
+ return fieldValue <= expected;
43
+ case '>=':
44
+ return fieldValue >= expected;
45
+ case 'contains':
46
+ if (typeof fieldValue === 'string')
47
+ return fieldValue.includes(expected);
48
+ if (Array.isArray(fieldValue))
49
+ return fieldValue.includes(expected);
50
+ return false;
51
+ case 'contains-any':
52
+ if (!Array.isArray(fieldValue) || !Array.isArray(expected))
53
+ return false;
54
+ return expected.some((value) => fieldValue.includes(value));
55
+ case 'in':
56
+ return Array.isArray(expected) ? expected.includes(fieldValue) : false;
57
+ case 'not in':
58
+ return Array.isArray(expected) ? !expected.includes(fieldValue) : true;
59
+ default:
60
+ return false;
61
+ }
62
+ }
63
+ function matchesAllFilters(data, filters, filterMatchFn) {
64
+ if (filters.length === 0)
65
+ return true;
66
+ if (filterMatchFn) {
67
+ return filterMatchFn(data, filters);
68
+ }
69
+ return filters.every(([field, operator, value]) => evaluateFilterCondition(data[field], operator, value));
70
+ }
71
+ function matchesAnyFilter(data, filters, filterMatchFn) {
72
+ if (filters.length === 0)
73
+ return true;
74
+ if (filterMatchFn) {
75
+ return filters.some((filter) => filterMatchFn(data, [filter]));
76
+ }
77
+ return filters.some(([field, operator, value]) => evaluateFilterCondition(data[field], operator, value));
78
+ }
79
+ function matchesSnapshotFilters(data, filters, orFilters, filterMatchFn) {
80
+ if (!matchesAllFilters(data, filters, filterMatchFn)) {
81
+ return false;
82
+ }
83
+ if (orFilters.length === 0) {
84
+ return true;
85
+ }
86
+ return matchesAnyFilter(data, orFilters, filterMatchFn);
87
+ }
88
+ // ─── Core dispatch helpers ───
89
+ /** Call the correct generated core method based on static vs dynamic DB. */
90
+ function coreGet(core, method, namespace, instanceId, table, args) {
91
+ const tablePath = encodeURIComponent(table);
92
+ const q = args.query ?? {};
93
+ if (instanceId) {
94
+ // Dynamic DB
95
+ switch (method) {
96
+ case 'list': return core.dbListRecords(namespace, instanceId, tablePath, q);
97
+ case 'get': return core.dbGetRecord(namespace, instanceId, tablePath, args.id, q);
98
+ case 'count': return core.dbCountRecords(namespace, instanceId, tablePath, q);
99
+ case 'search': return core.dbSearchRecords(namespace, instanceId, tablePath, q);
100
+ }
101
+ }
102
+ // Single-instance DB
103
+ switch (method) {
104
+ case 'list': return core.dbSingleListRecords(namespace, tablePath, q);
105
+ case 'get': return core.dbSingleGetRecord(namespace, tablePath, args.id, q);
106
+ case 'count': return core.dbSingleCountRecords(namespace, tablePath, q);
107
+ case 'search': return core.dbSingleSearchRecords(namespace, tablePath, q);
108
+ }
109
+ }
110
+ function coreInsert(core, namespace, instanceId, table, body, query = {}) {
111
+ const tablePath = encodeURIComponent(table);
112
+ if (instanceId) {
113
+ return core.dbInsertRecord(namespace, instanceId, tablePath, body, query);
114
+ }
115
+ return core.dbSingleInsertRecord(namespace, tablePath, body, query);
116
+ }
117
+ function coreUpdate(core, namespace, instanceId, table, id, body) {
118
+ const tablePath = encodeURIComponent(table);
119
+ if (instanceId) {
120
+ return core.dbUpdateRecord(namespace, instanceId, tablePath, id, body);
121
+ }
122
+ return core.dbSingleUpdateRecord(namespace, tablePath, id, body);
123
+ }
124
+ function coreDelete(core, namespace, instanceId, table, id) {
125
+ const tablePath = encodeURIComponent(table);
126
+ if (instanceId) {
127
+ return core.dbDeleteRecord(namespace, instanceId, tablePath, id);
128
+ }
129
+ return core.dbSingleDeleteRecord(namespace, tablePath, id);
130
+ }
131
+ function coreBatch(core, namespace, instanceId, table, body, query = {}) {
132
+ const tablePath = encodeURIComponent(table);
133
+ if (instanceId) {
134
+ return core.dbBatchRecords(namespace, instanceId, tablePath, body, query);
135
+ }
136
+ return core.dbSingleBatchRecords(namespace, tablePath, body, query);
137
+ }
138
+ function coreBatchByFilter(core, namespace, instanceId, table, body, query = {}) {
139
+ const tablePath = encodeURIComponent(table);
140
+ if (instanceId) {
141
+ return core.dbBatchByFilter(namespace, instanceId, tablePath, body, query);
142
+ }
143
+ return core.dbSingleBatchByFilter(namespace, tablePath, body, query);
144
+ }
145
+ // ─── DocRef ───
146
+ /**
147
+ * Document reference for single-record operations.
148
+ * Created via `client.db('shared').table('posts').doc('post-1')`
149
+ */
150
+ export class DocRef {
151
+ core;
152
+ namespace;
153
+ instanceId;
154
+ tableName;
155
+ id;
156
+ databaseLiveClient;
157
+ filterMatchFn;
158
+ constructor(core, namespace, instanceId, tableName, id, databaseLiveClient, filterMatchFn) {
159
+ this.core = core;
160
+ this.namespace = namespace;
161
+ this.instanceId = instanceId;
162
+ this.tableName = tableName;
163
+ this.id = id;
164
+ this.databaseLiveClient = databaseLiveClient;
165
+ this.filterMatchFn = filterMatchFn;
166
+ }
167
+ /** Get a single record */
168
+ async get() {
169
+ return coreGet(this.core, 'get', this.namespace, this.instanceId, this.tableName, { id: this.id, query: {} });
170
+ }
171
+ /** Update a record (supports increment, deleteField) */
172
+ async update(data) {
173
+ const serialized = serializeFieldOps(data);
174
+ return coreUpdate(this.core, this.namespace, this.instanceId, this.tableName, this.id, serialized);
175
+ }
176
+ /** Delete a record */
177
+ async delete() {
178
+ await coreDelete(this.core, this.namespace, this.instanceId, this.tableName, this.id);
179
+ }
180
+ /**
181
+ * Subscribe to database-live changes for this document.
182
+ * Returns an unsubscribe function.
183
+ *
184
+ * @example
185
+ * const unsub = client.db('shared').table('posts').doc('post-1').onSnapshot((post) => {
186
+ * console.log('Post updated:', post);
187
+ * });
188
+ */
189
+ onSnapshot(callback) {
190
+ if (!this.databaseLiveClient) {
191
+ throw new EdgeBaseError(500, 'IDatabaseLiveSubscriber not available');
192
+ }
193
+ const channel = buildDatabaseLiveChannel(this.namespace, this.instanceId, this.tableName, this.id);
194
+ return this.databaseLiveClient.onSnapshot(channel, (change) => {
195
+ callback(change.data, change);
196
+ });
197
+ }
198
+ }
199
+ // ─── TableRef ───
200
+ /**
201
+ * Collection reference with query builder (immutable chaining).
202
+ * Created via `client.db('shared').table('posts')`
203
+ *
204
+ * @example
205
+ * const result = await client.db('shared').table('posts')
206
+ * .where('status', '==', 'published')
207
+ * .orderBy('createdAt', 'desc')
208
+ * .limit(20)
209
+ * .getList();
210
+ */
211
+ export class TableRef {
212
+ core;
213
+ name;
214
+ databaseLiveClient;
215
+ filterMatchFn;
216
+ namespace;
217
+ instanceId;
218
+ _httpClient;
219
+ filters = [];
220
+ orFilters = []; //: OR conditions
221
+ sorts = [];
222
+ limitValue;
223
+ offsetValue;
224
+ pageValue;
225
+ searchQuery;
226
+ afterCursor;
227
+ beforeCursor;
228
+ constructor(core,
229
+ /** Table name within the DB block */
230
+ name, databaseLiveClient, filterMatchFn,
231
+ /** DB namespace: 'shared' | 'workspace' | 'user' | ... (§2) */
232
+ namespace = 'shared',
233
+ /** DB instance ID for dynamic DOs (e.g. 'ws-456'). Omit for static DBs. */
234
+ instanceId,
235
+ /**
236
+ * Raw HttpClient — only used for sql() which is admin-only and not in client core.
237
+ * TODO: remove once admin core is wired.
238
+ */
239
+ _httpClient) {
240
+ this.core = core;
241
+ this.name = name;
242
+ this.databaseLiveClient = databaseLiveClient;
243
+ this.filterMatchFn = filterMatchFn;
244
+ this.namespace = namespace;
245
+ this.instanceId = instanceId;
246
+ this._httpClient = _httpClient;
247
+ }
248
+ /** Create a clone with current state (for immutable chaining) */
249
+ clone() {
250
+ const ref = new TableRef(this.core, this.name, this.databaseLiveClient, this.filterMatchFn, this.namespace, this.instanceId, this._httpClient);
251
+ ref.filters = [...this.filters];
252
+ ref.orFilters = [...this.orFilters];
253
+ ref.sorts = [...this.sorts];
254
+ ref.limitValue = this.limitValue;
255
+ ref.offsetValue = this.offsetValue;
256
+ ref.pageValue = this.pageValue;
257
+ ref.searchQuery = this.searchQuery;
258
+ ref.afterCursor = this.afterCursor;
259
+ ref.beforeCursor = this.beforeCursor;
260
+ return ref;
261
+ }
262
+ /** Add a filter condition */
263
+ where(field, operator, value) {
264
+ const ref = this.clone();
265
+ ref.filters.push([field, operator, value]);
266
+ return ref;
267
+ }
268
+ /**
269
+ * Add OR conditions.
270
+ * Conditions inside the builder are joined with OR.
271
+ *
272
+ * @example
273
+ * client.db('shared').table('posts')
274
+ * .where('createdAt', '>', '2025-01-01') // AND
275
+ * .or(q => q.where('status', '==', 'draft').where('status', '==', 'archived')) // OR
276
+ * .getList()
277
+ */
278
+ or(builder) {
279
+ const ref = this.clone();
280
+ const orBuilder = new OrBuilder();
281
+ builder(orBuilder);
282
+ ref.orFilters = [...ref.orFilters, ...orBuilder.getFilters()];
283
+ return ref;
284
+ }
285
+ /** Add sort order (supports multiple — chained calls accumulate) */
286
+ orderBy(field, direction = 'asc') {
287
+ const ref = this.clone();
288
+ ref.sorts.push([field, direction]);
289
+ return ref;
290
+ }
291
+ /** Set result limit */
292
+ limit(n) {
293
+ const ref = this.clone();
294
+ ref.limitValue = n;
295
+ return ref;
296
+ }
297
+ /** Set result offset */
298
+ offset(n) {
299
+ const ref = this.clone();
300
+ ref.offsetValue = n;
301
+ return ref;
302
+ }
303
+ /** Set page number for offset pagination (1-based) */
304
+ page(n) {
305
+ const ref = this.clone();
306
+ ref.pageValue = n;
307
+ return ref;
308
+ }
309
+ /** Set full-text search query */
310
+ search(query) {
311
+ const ref = this.clone();
312
+ ref.searchQuery = query;
313
+ return ref;
314
+ }
315
+ /**
316
+ * Set cursor for forward pagination.
317
+ * Fetches records with id > cursor. Mutually exclusive with page()/offset().
318
+ */
319
+ after(cursor) {
320
+ const ref = this.clone();
321
+ ref.afterCursor = cursor;
322
+ ref.beforeCursor = undefined;
323
+ return ref;
324
+ }
325
+ /**
326
+ * Set cursor for backward pagination.
327
+ * Fetches records with id < cursor. Mutually exclusive with page()/offset().
328
+ */
329
+ before(cursor) {
330
+ const ref = this.clone();
331
+ ref.beforeCursor = cursor;
332
+ ref.afterCursor = undefined;
333
+ return ref;
334
+ }
335
+ /** Build query parameters from current state */
336
+ buildQueryParams() {
337
+ //: offset/cursor mutual exclusion
338
+ const hasCursor = this.afterCursor !== undefined || this.beforeCursor !== undefined;
339
+ const hasOffset = this.offsetValue !== undefined || this.pageValue !== undefined;
340
+ if (hasCursor && hasOffset) {
341
+ throw new EdgeBaseError(400, 'Cannot use page()/offset() with after()/before() — choose offset or cursor pagination');
342
+ }
343
+ const query = {};
344
+ if (this.filters.length > 0) {
345
+ query.filter = JSON.stringify(this.filters);
346
+ }
347
+ if (this.orFilters.length > 0) {
348
+ query.orFilter = JSON.stringify(this.orFilters);
349
+ }
350
+ if (this.sorts.length > 0) {
351
+ query.sort = this.sorts.map(([f, d]) => `${f}:${d}`).join(',');
352
+ }
353
+ if (this.limitValue !== undefined) {
354
+ query.limit = String(this.limitValue);
355
+ }
356
+ if (this.pageValue !== undefined) {
357
+ query.page = String(this.pageValue);
358
+ }
359
+ if (this.offsetValue !== undefined) {
360
+ query.offset = String(this.offsetValue);
361
+ }
362
+ if (this.afterCursor !== undefined) {
363
+ query.after = this.afterCursor;
364
+ }
365
+ if (this.beforeCursor !== undefined) {
366
+ query.before = this.beforeCursor;
367
+ }
368
+ return query;
369
+ }
370
+ /** Get a document reference for single-record operations */
371
+ doc(id) {
372
+ return new DocRef(this.core, this.namespace, this.instanceId, this.name, id, this.databaseLiveClient, this.filterMatchFn);
373
+ }
374
+ // ─── CRUD Methods ───
375
+ /**
376
+ * List records with filters, sorting, and pagination.
377
+ * Parses server response into unified ListResult with both offset and cursor fields.
378
+ */
379
+ async getList() {
380
+ const query = this.buildQueryParams();
381
+ let data;
382
+ if (this.searchQuery) {
383
+ query.search = this.searchQuery;
384
+ data = await coreGet(this.core, 'search', this.namespace, this.instanceId, this.name, { query });
385
+ }
386
+ else {
387
+ data = await coreGet(this.core, 'list', this.namespace, this.instanceId, this.name, { query });
388
+ }
389
+ return {
390
+ items: (data.items ?? []),
391
+ total: data.total !== undefined ? data.total : null,
392
+ page: data.page !== undefined ? data.page : null,
393
+ perPage: data.perPage !== undefined ? data.perPage : null,
394
+ hasMore: data.hasMore !== undefined ? data.hasMore : null,
395
+ cursor: data.cursor !== undefined ? data.cursor : null,
396
+ };
397
+ }
398
+ /** Alias for getList() to match existing docs and SDK usage. */
399
+ async get() {
400
+ return this.getList();
401
+ }
402
+ /** Get a single record by ID */
403
+ async getOne(id) {
404
+ return coreGet(this.core, 'get', this.namespace, this.instanceId, this.name, { id, query: {} });
405
+ }
406
+ /**
407
+ * Get the first record matching the current query conditions.
408
+ * Returns null if no records match.
409
+ *
410
+ * @example
411
+ * const user = await client.db('shared').table('users')
412
+ * .where('email', '==', 'june@example.com')
413
+ * .getFirst();
414
+ */
415
+ async getFirst() {
416
+ const result = await this.limit(1).getList();
417
+ return result.items[0] ?? null;
418
+ }
419
+ /** Insert a new record */
420
+ async insert(data) {
421
+ return coreInsert(this.core, this.namespace, this.instanceId, this.name, data);
422
+ }
423
+ /** Update a record by ID (supports increment, deleteField) */
424
+ async update(id, data) {
425
+ const serialized = serializeFieldOps(data);
426
+ return coreUpdate(this.core, this.namespace, this.instanceId, this.name, id, serialized);
427
+ }
428
+ /** Delete a record by ID */
429
+ async delete(id) {
430
+ await coreDelete(this.core, this.namespace, this.instanceId, this.name, id);
431
+ }
432
+ // ─── Special Methods ───
433
+ /** Upsert: insert or update */
434
+ async upsert(data, options) {
435
+ const query = { upsert: 'true' };
436
+ if (options?.conflictTarget)
437
+ query.conflictTarget = options.conflictTarget;
438
+ return coreInsert(this.core, this.namespace, this.instanceId, this.name, data, query);
439
+ }
440
+ /** Batch upsert */
441
+ async upsertMany(items, options) {
442
+ const CHUNK_SIZE = 500;
443
+ const query = { upsert: 'true' };
444
+ if (options?.conflictTarget)
445
+ query.conflictTarget = options.conflictTarget;
446
+ // Fast path: no chunking needed
447
+ if (items.length <= CHUNK_SIZE) {
448
+ const result = await coreBatch(this.core, this.namespace, this.instanceId, this.name, { inserts: items }, query);
449
+ return result.inserted;
450
+ }
451
+ // Chunk into 500-item batches
452
+ const allInserted = [];
453
+ for (let i = 0; i < items.length; i += CHUNK_SIZE) {
454
+ const chunk = items.slice(i, i + CHUNK_SIZE);
455
+ const result = await coreBatch(this.core, this.namespace, this.instanceId, this.name, { inserts: chunk }, query);
456
+ allInserted.push(...result.inserted);
457
+ }
458
+ return allInserted;
459
+ }
460
+ /** Count records matching filters */
461
+ async count() {
462
+ const query = this.buildQueryParams();
463
+ const result = await coreGet(this.core, 'count', this.namespace, this.instanceId, this.name, { query });
464
+ return result.total;
465
+ }
466
+ /**
467
+ * Batch insert.
468
+ * Auto-chunks into 500-item batches.
469
+ * Each chunk is an independent transaction — partial failure possible across chunks.
470
+ */
471
+ async insertMany(items) {
472
+ const CHUNK_SIZE = 500;
473
+ // Fast path: no chunking needed
474
+ if (items.length <= CHUNK_SIZE) {
475
+ const result = await coreBatch(this.core, this.namespace, this.instanceId, this.name, { inserts: items });
476
+ return result.inserted;
477
+ }
478
+ // Chunk into 500-item batches
479
+ const allInserted = [];
480
+ for (let i = 0; i < items.length; i += CHUNK_SIZE) {
481
+ const chunk = items.slice(i, i + CHUNK_SIZE);
482
+ const result = await coreBatch(this.core, this.namespace, this.instanceId, this.name, { inserts: chunk });
483
+ allInserted.push(...result.inserted);
484
+ }
485
+ return allInserted;
486
+ }
487
+ /**
488
+ * Batch update matching records.
489
+ * Processes 500 records per call, max 100 iterations.
490
+ */
491
+ async updateMany(data) {
492
+ const filter = this.filters;
493
+ if (filter.length === 0) {
494
+ throw new EdgeBaseError(400, 'updateMany requires at least one where() filter');
495
+ }
496
+ const serialized = serializeFieldOps(data);
497
+ return this.batchByFilter('update', filter, serialized);
498
+ }
499
+ /**
500
+ * Batch delete matching records.
501
+ * Processes 500 records per call, max 100 iterations.
502
+ */
503
+ async deleteMany() {
504
+ const filter = this.filters;
505
+ if (filter.length === 0) {
506
+ throw new EdgeBaseError(400, 'deleteMany requires at least one where() filter');
507
+ }
508
+ return this.batchByFilter('delete', filter);
509
+ }
510
+ /** Internal: batch-by-filter calls */
511
+ async batchByFilter(action, filter, update) {
512
+ const MAX_ITERATIONS = 100;
513
+ let totalProcessed = 0;
514
+ let totalSucceeded = 0;
515
+ const errors = [];
516
+ for (let chunkIndex = 0; chunkIndex < MAX_ITERATIONS; chunkIndex++) {
517
+ try {
518
+ const body = { action, filter, limit: 500 };
519
+ if (this.orFilters.length > 0)
520
+ body.orFilter = this.orFilters;
521
+ if (action === 'update' && update) {
522
+ body.update = update;
523
+ }
524
+ const result = await coreBatchByFilter(this.core, this.namespace, this.instanceId, this.name, body);
525
+ totalProcessed += result.processed;
526
+ totalSucceeded += result.succeeded;
527
+ if (result.processed === 0)
528
+ break; // No more matching records
529
+ // For 'update', don't loop — updated records still match the filter,
530
+ // so re-querying would process the same rows again (infinite loop).
531
+ // Only 'delete' benefits from looping since deleted rows disappear.
532
+ if (action === 'update')
533
+ break;
534
+ }
535
+ catch (error) {
536
+ errors.push({ chunkIndex, chunkSize: 500, error: error });
537
+ break; // Stop on error (partial failure)
538
+ }
539
+ }
540
+ return { totalProcessed, totalSucceeded, errors };
541
+ }
542
+ // ─── Database Live ───
543
+ /**
544
+ * Subscribe to table changes via database-live.
545
+ * By default, client-side filtering is applied for where() conditions.
546
+ * With `{ serverFilter: true }`, filters are evaluated server-side for bandwidth savings.
547
+ * Returns an unsubscribe function.
548
+ *
549
+ * @example
550
+ * // Client-side filtering (default)
551
+ * const unsub = client.db('shared').table('posts')
552
+ * .where('status', '==', 'published')
553
+ * .onSnapshot((snapshot) => {
554
+ * console.log('Updated posts:', snapshot.items);
555
+ * });
556
+ */
557
+ onSnapshot(callback, options) {
558
+ if (!this.databaseLiveClient) {
559
+ throw new EdgeBaseError(500, 'IDatabaseLiveSubscriber not available');
560
+ }
561
+ const channel = buildDatabaseLiveChannel(this.namespace, this.instanceId, this.name);
562
+ const currentFilters = [...this.filters];
563
+ const currentOrFilters = [...this.orFilters];
564
+ const useServerFilter = options?.serverFilter === true && (currentFilters.length > 0 || currentOrFilters.length > 0);
565
+ // Accumulate state locally
566
+ const items = new Map();
567
+ const pendingChanges = { added: [], modified: [], removed: [] };
568
+ let flushTimer = null;
569
+ const flush = () => {
570
+ if (pendingChanges.added.length || pendingChanges.modified.length || pendingChanges.removed.length) {
571
+ callback({
572
+ items: Array.from(items.values()),
573
+ changes: { ...pendingChanges },
574
+ });
575
+ pendingChanges.added = [];
576
+ pendingChanges.modified = [];
577
+ pendingChanges.removed = [];
578
+ }
579
+ flushTimer = null;
580
+ };
581
+ const scheduleFlush = () => {
582
+ if (!flushTimer) {
583
+ flushTimer = setTimeout(flush, 0);
584
+ }
585
+ };
586
+ return this.databaseLiveClient.onSnapshot(channel, (change) => {
587
+ const data = change.data;
588
+ const docId = change.docId;
589
+ const hasFilterConstraints = currentFilters.length > 0 || currentOrFilters.length > 0;
590
+ const hadItem = items.has(docId);
591
+ // Client-side filtering — skip if server-side filter is active
592
+ if (!useServerFilter && hasFilterConstraints) {
593
+ if (change.changeType === 'removed') {
594
+ if (!hadItem)
595
+ return;
596
+ }
597
+ else if (!data || !matchesSnapshotFilters(data, currentFilters, currentOrFilters, this.filterMatchFn)) {
598
+ if (hadItem) {
599
+ items.delete(docId);
600
+ pendingChanges.removed.push((data ?? { id: docId }));
601
+ scheduleFlush();
602
+ }
603
+ return;
604
+ }
605
+ }
606
+ switch (change.changeType) {
607
+ case 'added':
608
+ if (data)
609
+ items.set(docId, data);
610
+ pendingChanges[hadItem ? 'modified' : 'added'].push(data);
611
+ break;
612
+ case 'modified':
613
+ if (data)
614
+ items.set(docId, data);
615
+ pendingChanges[hadItem ? 'modified' : 'added'].push(data);
616
+ break;
617
+ case 'removed':
618
+ items.delete(docId);
619
+ pendingChanges.removed.push((data ?? { id: docId }));
620
+ break;
621
+ }
622
+ scheduleFlush();
623
+ }, undefined, // client-side filters (not used for server-side)
624
+ useServerFilter ? currentFilters : undefined, // server-side filters
625
+ useServerFilter ? currentOrFilters : undefined);
626
+ }
627
+ /**
628
+ * Execute raw SQL on this table's DO.
629
+ * Tagged template — interpolated values are automatically extracted as bind params,
630
+ * preventing SQL injection.
631
+ *
632
+ * NOTE: Admin-only (/api/sql). Uses raw HttpClient since this endpoint is
633
+ * in the admin core, not the client core. Requires Service Key auth.
634
+ *
635
+ * @example
636
+ * // In App Functions or server-side code
637
+ * const results = await context.admin.db('shared').table('posts').sql`
638
+ * SELECT p.*, COUNT(c.id) as commentCount
639
+ * FROM posts p LEFT JOIN comments c ON c.postId = p.id
640
+ * WHERE p.status = ${'published'}
641
+ * GROUP BY p.id
642
+ * `;
643
+ */
644
+ async sql(strings, ...values) {
645
+ if (!this._httpClient) {
646
+ throw new EdgeBaseError(500, 'sql() requires HttpClient (admin-only method). Use context.admin.db(...).table(...).sql`...`');
647
+ }
648
+ // Build parameterized query from tagged template — each ${} becomes ?
649
+ let query = '';
650
+ const params = [];
651
+ for (let i = 0; i < strings.length; i++) {
652
+ query += strings[i];
653
+ if (i < values.length) {
654
+ query += '?';
655
+ params.push(values[i]);
656
+ }
657
+ }
658
+ // §11: body uses { namespace, id, sql, params }
659
+ // /api/sql is admin-only, tagged 'admin', lives in admin core — not client core.
660
+ return this._httpClient.post(ApiPaths.EXECUTE_SQL, {
661
+ namespace: this.namespace,
662
+ id: this.instanceId,
663
+ sql: query.trim(),
664
+ params,
665
+ });
666
+ }
667
+ }
668
+ // ─── DbRef ───
669
+ /**
670
+ * DB block reference. Returned by `client.db(namespace, id?)`.
671
+ * Provides `.table(name)` to get a TableRef for a specific table.
672
+ *
673
+ * @example
674
+ * // Static shared DB
675
+ * const postsRef = client.db('shared').table('posts');
676
+ *
677
+ * // Dynamic workspace DB
678
+ * const docsRef = client.db('workspace', 'ws-456').table('documents');
679
+ */
680
+ export class DbRef {
681
+ core;
682
+ namespace;
683
+ instanceId;
684
+ databaseLiveClient;
685
+ filterMatchFn;
686
+ _httpClient;
687
+ constructor(core,
688
+ /** DB namespace: 'shared' | 'workspace' | 'user' | ... */
689
+ namespace,
690
+ /** DB instance ID for dynamic DOs (e.g. 'ws-456'). Omit for static DBs. */
691
+ instanceId, databaseLiveClient, filterMatchFn,
692
+ /**
693
+ * Raw HttpClient — only passed through to TableRef for sql().
694
+ * TODO: remove once admin core is wired.
695
+ */
696
+ _httpClient) {
697
+ this.core = core;
698
+ this.namespace = namespace;
699
+ this.instanceId = instanceId;
700
+ this.databaseLiveClient = databaseLiveClient;
701
+ this.filterMatchFn = filterMatchFn;
702
+ this._httpClient = _httpClient;
703
+ }
704
+ /**
705
+ * Select a table within this DB block.
706
+ * Returns a TableRef configured with the correct namespace/instanceId.
707
+ *
708
+ * @param name — Table name (as defined in config.databases[namespace].tables)
709
+ */
710
+ table(name) {
711
+ return new TableRef(this.core, name, this.databaseLiveClient, this.filterMatchFn, this.namespace, this.instanceId, this._httpClient);
712
+ }
713
+ }
714
+ // ─── OrBuilder ───
715
+ /**
716
+ * Builder for OR conditions.
717
+ * Used with .or() method on TableRef.
718
+ *
719
+ * @example
720
+ * client.db('shared').table('posts')
721
+ * .or(q => q.where('status', '==', 'draft').where('authorId', '==', 'user-123'))
722
+ * .getList()
723
+ */
724
+ export class OrBuilder {
725
+ filters = [];
726
+ where(field, operator, value) {
727
+ this.filters.push([field, operator, value]);
728
+ return this;
729
+ }
730
+ getFilters() {
731
+ return [...this.filters];
732
+ }
733
+ }
734
+ //# sourceMappingURL=table.js.map