@spooky-sync/core 0.0.0-canary.1 → 0.0.1-canary.4

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/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as surrealdb0 from "surrealdb";
2
2
  import { Duration, RecordId, Surreal, SurrealTransaction } from "surrealdb";
3
- import { AccessDefinition, BackendNames, BackendRoutes, ColumnSchema, GetTable, QueryBuilder, QueryOptions, RecordId as RecordId$1, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
3
+ import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, GetTable, QueryBuilder, QueryOptions, RecordId as RecordId$1, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
4
4
  import { Level, Level as Level$1, Logger } from "pino";
5
5
 
6
6
  //#region src/events/index.d.ts
@@ -544,6 +544,19 @@ declare class AuthService<S extends SchemaStructure> {
544
544
  }
545
545
  //#endregion
546
546
  //#region src/spooky.d.ts
547
+ declare class BucketHandle {
548
+ private bucketName;
549
+ private remote;
550
+ constructor(bucketName: string, remote: RemoteDatabaseService);
551
+ put(path: string, content: string | Uint8Array | Blob): Promise<void>;
552
+ get(path: string): Promise<unknown>;
553
+ delete(path: string): Promise<void>;
554
+ exists(path: string): Promise<boolean>;
555
+ head(path: string): Promise<Record<string, unknown>>;
556
+ copy(sourcePath: string, targetPath: string): Promise<void>;
557
+ rename(sourcePath: string, targetPath: string): Promise<void>;
558
+ list(prefix?: string): Promise<string[]>;
559
+ }
547
560
  declare class SpookyClient<S extends SchemaStructure> {
548
561
  private config;
549
562
  private local;
@@ -577,6 +590,7 @@ declare class SpookyClient<S extends SchemaStructure> {
577
590
  immediate?: boolean;
578
591
  }): Promise<() => void>;
579
592
  run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(backend: B, path: R, payload: RoutePayload<S, B, R>, options?: RunOptions): Promise<void>;
593
+ bucket<B extends BucketNames<S>>(name: B): BucketHandle;
580
594
  create(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
581
595
  update(table: string, id: string, data: Record<string, unknown>, options?: UpdateOptions): Promise<{
582
596
  [x: string]: /*elided*/any;
@@ -587,4 +601,11 @@ declare class SpookyClient<S extends SchemaStructure> {
587
601
  private loadOrGenerateClientId;
588
602
  }
589
603
  //#endregion
590
- export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, DebounceOptions, EventSubscriptionOptions, type Level, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryTimeToLive, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RunOptions, SpookyClient, SpookyConfig, SpookyQueryResult, SpookyQueryResultPromise, StoreType, UpdateOptions, createAuthEventSystem };
604
+ //#region src/utils/index.d.ts
605
+ declare function fileToUint8Array(file: File | Blob): Promise<Uint8Array>;
606
+ /**
607
+ * Helper for retrying DB operations with exponential backoff
608
+ */
609
+
610
+ //#endregion
611
+ export { AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, DebounceOptions, EventSubscriptionOptions, type Level, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryTimeToLive, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RunOptions, SpookyClient, SpookyConfig, SpookyQueryResult, SpookyQueryResultPromise, StoreType, UpdateOptions, createAuthEventSystem, fileToUint8Array };
package/dist/index.js CHANGED
@@ -169,6 +169,10 @@ function parseDuration(duration) {
169
169
  default: return val * 6e4;
170
170
  }
171
171
  }
172
+ async function fileToUint8Array(file) {
173
+ const buffer = await file.arrayBuffer();
174
+ return new Uint8Array(buffer);
175
+ }
172
176
  /**
173
177
  * Helper for retrying DB operations with exponential backoff
174
178
  */
@@ -204,6 +208,7 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
204
208
  */
205
209
  var DataModule = class {
206
210
  activeQueries = /* @__PURE__ */ new Map();
211
+ pendingQueries = /* @__PURE__ */ new Map();
207
212
  subscriptions = /* @__PURE__ */ new Map();
208
213
  mutationCallbacks = /* @__PURE__ */ new Set();
209
214
  debounceTimers = /* @__PURE__ */ new Map();
@@ -238,36 +243,25 @@ var DataModule = class {
238
243
  }, "Query Initialization: exists, returning");
239
244
  return hash;
240
245
  }
246
+ if (this.pendingQueries.has(hash)) {
247
+ this.logger.debug({
248
+ hash,
249
+ Category: "spooky-client::DataModule::query"
250
+ }, "Query Initialization: pending, waiting for existing creation");
251
+ await this.pendingQueries.get(hash);
252
+ return hash;
253
+ }
241
254
  this.logger.debug({
242
255
  hash,
243
256
  Category: "spooky-client::DataModule::query"
244
257
  }, "Query Initialization: not found, creating new query");
245
- const queryState = await this.createNewQuery({
246
- recordId,
247
- surql: surqlString,
248
- params,
249
- ttl,
250
- tableName
251
- });
252
- const { localArray } = this.cache.registerQuery({
253
- queryHash: hash,
254
- surql: surqlString,
255
- params,
256
- ttl: new Duration(ttl),
257
- lastActiveAt: /* @__PURE__ */ new Date()
258
- });
259
- await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
260
- id: recordId,
261
- localArray
262
- }));
263
- this.activeQueries.set(hash, queryState);
264
- this.startTTLHeartbeat(queryState);
265
- this.logger.debug({
266
- hash,
267
- tableName,
268
- recordCount: queryState.records.length,
269
- Category: "spooky-client::DataModule::query"
270
- }, "Query registered");
258
+ const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName);
259
+ this.pendingQueries.set(hash, promise);
260
+ try {
261
+ await promise;
262
+ } finally {
263
+ this.pendingQueries.delete(hash);
264
+ }
271
265
  return hash;
272
266
  }
273
267
  /**
@@ -323,13 +317,23 @@ var DataModule = class {
323
317
  }
324
318
  try {
325
319
  const [records] = await this.local.query(queryState.config.surql, queryState.config.params);
326
- queryState.records = records || [];
320
+ const newRecords = records || [];
327
321
  queryState.config.localArray = localArray;
328
- queryState.updateCount++;
329
322
  await this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
330
323
  id: queryState.config.id,
331
324
  localArray
332
325
  });
326
+ const prevJson = JSON.stringify(queryState.records);
327
+ const newJson = JSON.stringify(newRecords);
328
+ queryState.records = newRecords;
329
+ if (prevJson === newJson) {
330
+ this.logger.debug({
331
+ queryHash,
332
+ Category: "spooky-client::DataModule::onStreamUpdate"
333
+ }, "Query records unchanged, skipping notification");
334
+ return;
335
+ }
336
+ queryState.updateCount++;
333
337
  const subscribers = this.subscriptions.get(queryHash);
334
338
  if (subscribers) for (const callback of subscribers) callback(queryState.records);
335
339
  this.logger.debug({
@@ -393,6 +397,23 @@ var DataModule = class {
393
397
  remoteArray
394
398
  });
395
399
  }
400
+ /**
401
+ * Called after a query's initial sync completes.
402
+ * Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
403
+ */
404
+ async notifyQuerySynced(queryHash) {
405
+ const queryState = this.activeQueries.get(queryHash);
406
+ if (!queryState) return;
407
+ const [records] = await this.local.query(queryState.config.surql, queryState.config.params);
408
+ const newRecords = records || [];
409
+ const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
410
+ queryState.records = newRecords;
411
+ if (changed || queryState.updateCount === 0) {
412
+ queryState.updateCount++;
413
+ const subscribers = this.subscriptions.get(queryHash);
414
+ if (subscribers) for (const callback of subscribers) callback(queryState.records);
415
+ }
416
+ }
396
417
  async run(backend, path, data, options) {
397
418
  const route = this.schema.backends?.[backend]?.routes?.[path];
398
419
  if (!route) throw new Error(`Route ${backend}.${path} not found`);
@@ -482,7 +503,10 @@ var DataModule = class {
482
503
  mid: mutationId,
483
504
  data: params
484
505
  }));
485
- this.replaceRecordInQueries(target);
506
+ const updatedFields = { id: target.id };
507
+ for (const key of Object.keys(data)) if (key in target) updatedFields[key] = target[key];
508
+ if ("spooky_rv" in target) updatedFields.spooky_rv = target.spooky_rv;
509
+ this.replaceRecordInQueries(updatedFields);
486
510
  const parsedRecord = parseParams(tableSchema.columns, target);
487
511
  await this.cache.save({
488
512
  table,
@@ -605,6 +629,35 @@ var DataModule = class {
605
629
  }
606
630
  }
607
631
  }
632
+ async createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName) {
633
+ const queryState = await this.createNewQuery({
634
+ recordId,
635
+ surql: surqlString,
636
+ params,
637
+ ttl,
638
+ tableName
639
+ });
640
+ const { localArray } = this.cache.registerQuery({
641
+ queryHash: hash,
642
+ surql: surqlString,
643
+ params,
644
+ ttl: new Duration(ttl),
645
+ lastActiveAt: /* @__PURE__ */ new Date()
646
+ });
647
+ await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
648
+ id: recordId,
649
+ localArray
650
+ }));
651
+ this.activeQueries.set(hash, queryState);
652
+ this.startTTLHeartbeat(queryState);
653
+ this.logger.debug({
654
+ hash,
655
+ tableName,
656
+ recordCount: queryState.records.length,
657
+ Category: "spooky-client::DataModule::query"
658
+ }, "Query registered");
659
+ return hash;
660
+ }
608
661
  async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName }) {
609
662
  const tableSchema = this.schema.tables.find((t) => t.name === tableName);
610
663
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
@@ -671,11 +724,17 @@ var DataModule = class {
671
724
  }
672
725
  }
673
726
  async replaceRecordInQueries(record) {
674
- for (const queryState of this.activeQueries.values()) this.replaceRecordInQuery(queryState, record);
675
- }
676
- replaceRecordInQuery(queryState, record) {
677
- const index = queryState.records.findIndex((r) => r.id === record.id);
678
- if (index !== -1) queryState.records[index] = record;
727
+ for (const [queryHash, queryState] of this.activeQueries.entries()) {
728
+ const index = queryState.records.findIndex((r) => r.id === record.id);
729
+ if (index !== -1) {
730
+ queryState.records[index] = {
731
+ ...queryState.records[index],
732
+ ...record
733
+ };
734
+ const subscribers = this.subscriptions.get(queryHash);
735
+ if (subscribers) for (const callback of subscribers) callback(queryState.records);
736
+ }
737
+ }
679
738
  }
680
739
  };
681
740
  /**
@@ -1966,6 +2025,7 @@ var SpookySync = class {
1966
2025
  }, "Register Query state");
1967
2026
  await this.createRemoteQuery(queryHash);
1968
2027
  await this.syncQuery(queryHash);
2028
+ await this.dataModule.notifyQuerySynced(queryHash);
1969
2029
  } catch (e) {
1970
2030
  this.logger.error({
1971
2031
  err: e,
@@ -2912,6 +2972,41 @@ var SurrealDBPersistenceClient = class {
2912
2972
 
2913
2973
  //#endregion
2914
2974
  //#region src/spooky.ts
2975
+ var BucketHandle = class {
2976
+ constructor(bucketName, remote) {
2977
+ this.bucketName = bucketName;
2978
+ this.remote = remote;
2979
+ }
2980
+ async put(path, content) {
2981
+ await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
2982
+ }
2983
+ async get(path) {
2984
+ const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".get();`);
2985
+ return result;
2986
+ }
2987
+ async delete(path) {
2988
+ await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
2989
+ }
2990
+ async exists(path) {
2991
+ const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".exists();`);
2992
+ return result;
2993
+ }
2994
+ async head(path) {
2995
+ const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".head();`);
2996
+ return result;
2997
+ }
2998
+ async copy(sourcePath, targetPath) {
2999
+ await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, { target: targetPath });
3000
+ }
3001
+ async rename(sourcePath, targetPath) {
3002
+ await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, { target: targetPath });
3003
+ }
3004
+ async list(prefix) {
3005
+ const p = prefix ?? "";
3006
+ const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${p}".list();`);
3007
+ return result;
3008
+ }
3009
+ };
2915
3010
  var SpookyClient = class {
2916
3011
  local;
2917
3012
  remote;
@@ -3047,6 +3142,9 @@ var SpookyClient = class {
3047
3142
  run(backend, path, payload, options) {
3048
3143
  return this.dataModule.run(backend, path, payload, options);
3049
3144
  }
3145
+ bucket(name) {
3146
+ return new BucketHandle(name, this.remote);
3147
+ }
3050
3148
  create(id, data) {
3051
3149
  return this.dataModule.create(id, data);
3052
3150
  }
@@ -3079,4 +3177,4 @@ var SpookyClient = class {
3079
3177
  };
3080
3178
 
3081
3179
  //#endregion
3082
- export { AuthEventTypes, AuthService, SpookyClient, createAuthEventSystem };
3180
+ export { AuthEventTypes, AuthService, BucketHandle, SpookyClient, createAuthEventSystem, fileToUint8Array };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.0-canary.1",
3
+ "version": "0.0.1-canary.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -43,4 +43,4 @@
43
43
  "typescript": "^5.6.2",
44
44
  "vitest": "^3.2.0"
45
45
  }
46
- }
46
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './types';
2
2
  export * from './spooky';
3
3
  export * from './modules/auth/index';
4
+ export { fileToUint8Array } from './utils/index';
@@ -45,6 +45,7 @@ import { PushEventOptions } from '../../events/index';
45
45
  */
46
46
  export class DataModule<S extends SchemaStructure> {
47
47
  private activeQueries: Map<QueryHash, QueryState> = new Map();
48
+ private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
48
49
  private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
49
50
  private mutationCallbacks: Set<MutationCallback> = new Set();
50
51
  private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
@@ -91,44 +92,29 @@ export class DataModule<S extends SchemaStructure> {
91
92
  return hash;
92
93
  }
93
94
 
95
+ // Another call is already creating this query — wait for it
96
+ if (this.pendingQueries.has(hash)) {
97
+ this.logger.debug(
98
+ { hash, Category: 'spooky-client::DataModule::query' },
99
+ 'Query Initialization: pending, waiting for existing creation'
100
+ );
101
+ await this.pendingQueries.get(hash);
102
+ return hash;
103
+ }
104
+
94
105
  this.logger.debug(
95
106
  { hash, Category: 'spooky-client::DataModule::query' },
96
107
  'Query Initialization: not found, creating new query'
97
108
  );
98
- const queryState = await this.createNewQuery<T>({
99
- recordId,
100
- surql: surqlString,
101
- params,
102
- ttl,
103
- tableName,
104
- });
105
-
106
- const { localArray } = this.cache.registerQuery({
107
- queryHash: hash,
108
- surql: surqlString,
109
- params,
110
- ttl: new Duration(ttl),
111
- lastActiveAt: new Date(),
112
- });
113
-
114
- await withRetry(this.logger, () =>
115
- this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
116
- id: recordId,
117
- localArray,
118
- })
119
- );
120
109
 
121
- this.activeQueries.set(hash, queryState);
122
- this.startTTLHeartbeat(queryState);
123
- this.logger.debug(
124
- {
125
- hash,
126
- tableName,
127
- recordCount: queryState.records.length,
128
- Category: 'spooky-client::DataModule::query',
129
- },
130
- 'Query registered'
131
- );
110
+ // Create the query and track the pending promise
111
+ const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName);
112
+ this.pendingQueries.set(hash, promise);
113
+ try {
114
+ await promise;
115
+ } finally {
116
+ this.pendingQueries.delete(hash);
117
+ }
132
118
 
133
119
  return hash;
134
120
  }
@@ -222,14 +208,27 @@ export class DataModule<S extends SchemaStructure> {
222
208
  );
223
209
 
224
210
  // Update state
225
- queryState.records = records || [];
211
+ const newRecords = records || [];
226
212
  queryState.config.localArray = localArray;
227
- queryState.updateCount++;
228
213
  await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
229
214
  id: queryState.config.id,
230
215
  localArray,
231
216
  });
232
217
 
218
+ // Skip notification if records haven't changed
219
+ const prevJson = JSON.stringify(queryState.records);
220
+ const newJson = JSON.stringify(newRecords);
221
+ queryState.records = newRecords;
222
+ if (prevJson === newJson) {
223
+ this.logger.debug(
224
+ { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
225
+ 'Query records unchanged, skipping notification'
226
+ );
227
+ return;
228
+ }
229
+
230
+ queryState.updateCount++;
231
+
233
232
  // Notify subscribers
234
233
  const subscribers = this.subscriptions.get(queryHash);
235
234
  if (subscribers) {
@@ -307,6 +306,36 @@ export class DataModule<S extends SchemaStructure> {
307
306
  });
308
307
  }
309
308
 
309
+ /**
310
+ * Called after a query's initial sync completes.
311
+ * Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
312
+ */
313
+ async notifyQuerySynced(queryHash: string): Promise<void> {
314
+ const queryState = this.activeQueries.get(queryHash);
315
+ if (!queryState) return;
316
+
317
+ // Re-query local DB for latest data
318
+ const [records] = await this.local.query<[Record<string, any>[]]>(
319
+ queryState.config.surql,
320
+ queryState.config.params
321
+ );
322
+ const newRecords = records || [];
323
+ const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
324
+ queryState.records = newRecords;
325
+
326
+ // Notify if data changed OR if this is the first sync (updateCount === 0)
327
+ // The latter handles "query truly has no results" so UI can stop loading
328
+ if (changed || queryState.updateCount === 0) {
329
+ queryState.updateCount++;
330
+ const subscribers = this.subscriptions.get(queryHash);
331
+ if (subscribers) {
332
+ for (const callback of subscribers) {
333
+ callback(queryState.records);
334
+ }
335
+ }
336
+ }
337
+ }
338
+
310
339
  // ==================== RUN JOBS ====================
311
340
 
312
341
  async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
@@ -458,10 +487,19 @@ export class DataModule<S extends SchemaStructure> {
458
487
  })
459
488
  );
460
489
 
461
- // Replace record in all queries directly
462
- // Does not respect sorting or other advanced query features
463
- // But is fast for quick typing for example
464
- this.replaceRecordInQueries(target);
490
+ // Build a partial record with only the fields the user actually changed
491
+ // This avoids overwriting rich relation objects (e.g. author: {id, name, ...})
492
+ // with flat RecordIds from the UPDATE...MERGE result
493
+ const updatedFields: Record<string, any> = { id: target.id };
494
+ for (const key of Object.keys(data)) {
495
+ if (key in target) {
496
+ updatedFields[key] = (target as Record<string, any>)[key];
497
+ }
498
+ }
499
+ if ('spooky_rv' in (target as Record<string, any>)) {
500
+ updatedFields.spooky_rv = (target as Record<string, any>).spooky_rv;
501
+ }
502
+ this.replaceRecordInQueries(updatedFields);
465
503
 
466
504
  const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
467
505
 
@@ -634,6 +672,52 @@ export class DataModule<S extends SchemaStructure> {
634
672
 
635
673
  // ==================== PRIVATE HELPERS ====================
636
674
 
675
+ private async createAndRegisterQuery<T extends TableNames<S>>(
676
+ hash: QueryHash,
677
+ recordId: RecordId,
678
+ surqlString: string,
679
+ params: Record<string, any>,
680
+ ttl: QueryTimeToLive,
681
+ tableName: T
682
+ ): Promise<QueryHash> {
683
+ const queryState = await this.createNewQuery<T>({
684
+ recordId,
685
+ surql: surqlString,
686
+ params,
687
+ ttl,
688
+ tableName,
689
+ });
690
+
691
+ const { localArray } = this.cache.registerQuery({
692
+ queryHash: hash,
693
+ surql: surqlString,
694
+ params,
695
+ ttl: new Duration(ttl),
696
+ lastActiveAt: new Date(),
697
+ });
698
+
699
+ await withRetry(this.logger, () =>
700
+ this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
701
+ id: recordId,
702
+ localArray,
703
+ })
704
+ );
705
+
706
+ this.activeQueries.set(hash, queryState);
707
+ this.startTTLHeartbeat(queryState);
708
+ this.logger.debug(
709
+ {
710
+ hash,
711
+ tableName,
712
+ recordCount: queryState.records.length,
713
+ Category: 'spooky-client::DataModule::query',
714
+ },
715
+ 'Query registered'
716
+ );
717
+
718
+ return hash;
719
+ }
720
+
637
721
  private async createNewQuery<T extends TableNames<S>>({
638
722
  recordId,
639
723
  surql: surqlString,
@@ -736,15 +820,18 @@ export class DataModule<S extends SchemaStructure> {
736
820
  }
737
821
 
738
822
  private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
739
- for (const queryState of this.activeQueries.values()) {
740
- this.replaceRecordInQuery(queryState, record);
741
- }
742
- }
743
-
744
- private replaceRecordInQuery(queryState: QueryState, record: Record<string, any>): void {
745
- const index = queryState.records.findIndex((r) => r.id === record.id);
746
- if (index !== -1) {
747
- queryState.records[index] = record;
823
+ for (const [queryHash, queryState] of this.activeQueries.entries()) {
824
+ const index = queryState.records.findIndex((r) => r.id === record.id);
825
+ if (index !== -1) {
826
+ queryState.records[index] = { ...queryState.records[index], ...record };
827
+ // Notify subscribers so UI updates immediately
828
+ const subscribers = this.subscriptions.get(queryHash);
829
+ if (subscribers) {
830
+ for (const callback of subscribers) {
831
+ callback(queryState.records);
832
+ }
833
+ }
834
+ }
748
835
  }
749
836
  }
750
837
  }
@@ -314,6 +314,9 @@ export class SpookySync<S extends SchemaStructure> {
314
314
  );
315
315
  await this.createRemoteQuery(queryHash);
316
316
  await this.syncQuery(queryHash);
317
+ // Always notify after sync completes — handles empty result sets
318
+ // where no stream updates fire but the UI needs to stop loading
319
+ await this.dataModule.notifyQuerySynced(queryHash);
317
320
  } catch (e) {
318
321
  this.logger.error(
319
322
  { err: e, Category: 'spooky-client::SpookySync::registerQuery' },
package/src/spooky.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  SchemaStructure,
24
24
  TableModel,
25
25
  TableNames,
26
+ BucketNames,
26
27
  BackendNames,
27
28
  BackendRoutes,
28
29
  RoutePayload,
@@ -38,6 +39,47 @@ import { LocalStoragePersistenceClient } from './services/persistence/localstora
38
39
  import { generateId, parseParams } from './utils/index';
39
40
  import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
40
41
 
42
+ export class BucketHandle {
43
+ constructor(private bucketName: string, private remote: RemoteDatabaseService) {}
44
+
45
+ async put(path: string, content: string | Uint8Array | Blob): Promise<void> {
46
+ await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
47
+ }
48
+
49
+ async get(path: string): Promise<unknown> {
50
+ const [result] = await this.remote.query<[unknown]>(`RETURN f"${this.bucketName}:/${path}".get();`);
51
+ return result;
52
+ }
53
+
54
+ async delete(path: string): Promise<void> {
55
+ await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
56
+ }
57
+
58
+ async exists(path: string): Promise<boolean> {
59
+ const [result] = await this.remote.query<[boolean]>(`RETURN f"${this.bucketName}:/${path}".exists();`);
60
+ return result;
61
+ }
62
+
63
+ async head(path: string): Promise<Record<string, unknown>> {
64
+ const [result] = await this.remote.query<[Record<string, unknown>]>(`RETURN f"${this.bucketName}:/${path}".head();`);
65
+ return result;
66
+ }
67
+
68
+ async copy(sourcePath: string, targetPath: string): Promise<void> {
69
+ await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, { target: targetPath });
70
+ }
71
+
72
+ async rename(sourcePath: string, targetPath: string): Promise<void> {
73
+ await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, { target: targetPath });
74
+ }
75
+
76
+ async list(prefix?: string): Promise<string[]> {
77
+ const p = prefix ?? '';
78
+ const [result] = await this.remote.query<[string[]]>(`RETURN f"${this.bucketName}:/${p}".list();`);
79
+ return result;
80
+ }
81
+ }
82
+
41
83
  export class SpookyClient<S extends SchemaStructure> {
42
84
  private local: LocalDatabaseService;
43
85
  private remote: RemoteDatabaseService;
@@ -305,6 +347,10 @@ export class SpookyClient<S extends SchemaStructure> {
305
347
  return this.dataModule.run(backend, path, payload, options);
306
348
  }
307
349
 
350
+ bucket<B extends BucketNames<S>>(name: B): BucketHandle {
351
+ return new BucketHandle(name, this.remote);
352
+ }
353
+
308
354
  create(id: string, data: Record<string, unknown>) {
309
355
  return this.dataModule.create(id, data);
310
356
  }
@@ -130,6 +130,13 @@ export function parseDuration(duration: QueryTimeToLive | Duration): number {
130
130
  }
131
131
  }
132
132
 
133
+ // ==================== FILE UTILITIES ====================
134
+
135
+ export async function fileToUint8Array(file: File | Blob): Promise<Uint8Array> {
136
+ const buffer = await file.arrayBuffer();
137
+ return new Uint8Array(buffer);
138
+ }
139
+
133
140
  // ==================== DATABASE UTILITIES ====================
134
141
 
135
142
  /**