@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 +23 -2
- package/dist/index.js +133 -35
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/modules/data/index.ts +135 -48
- package/src/modules/sync/sync.ts +3 -0
- package/src/spooky.ts +46 -0
- package/src/utils/index.ts +7 -0
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
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
122
|
-
this.
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
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.
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
}
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -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
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
/**
|