@spooky-sync/core 0.0.1-canary.75 → 0.0.1-canary.76
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 +67 -0
- package/dist/index.js +212 -5
- package/dist/types.d.ts +8 -0
- package/package.json +3 -3
- package/src/modules/auth/index.ts +42 -0
- package/src/modules/data/index.ts +64 -0
- package/src/modules/sync/sync.ts +129 -6
- package/src/modules/sync/utils.test.ts +30 -0
- package/src/modules/sync/utils.ts +16 -0
- package/src/services/stream-processor/index.ts +43 -1
- package/src/sp00ky.ts +11 -1
- package/src/types.ts +8 -0
package/dist/index.d.ts
CHANGED
|
@@ -186,6 +186,7 @@ declare class StreamProcessorService {
|
|
|
186
186
|
private receivers;
|
|
187
187
|
private batching;
|
|
188
188
|
private batchBuffer;
|
|
189
|
+
private sessionAuth;
|
|
189
190
|
constructor(events: EventSystem<StreamProcessorEvents>, db: LocalDatabaseService, persistenceClient: PersistenceClient, logger: Logger);
|
|
190
191
|
/**
|
|
191
192
|
* Add a receiver for stream updates.
|
|
@@ -240,6 +241,17 @@ declare class StreamProcessorService {
|
|
|
240
241
|
* non-`_00_` tables are default-denied and registration fails.
|
|
241
242
|
*/
|
|
242
243
|
setPermissions(permissions: Record<string, string>): void;
|
|
244
|
+
/**
|
|
245
|
+
* Set the current session's auth identity for permission injection,
|
|
246
|
+
* mirroring the server's `fn::query::register`
|
|
247
|
+
* (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
|
|
248
|
+
* Stored as strings (empty when logged out) and applied to every
|
|
249
|
+
* `register_view` in {@link registerQueryPlan}. Must be set before a
|
|
250
|
+
* `$auth`-gated query registers (and re-set on auth state changes), or the
|
|
251
|
+
* in-browser SSP's `permission_inject` rejects it with
|
|
252
|
+
* "requires $auth but registration params lack it".
|
|
253
|
+
*/
|
|
254
|
+
setSessionAuth(authId: string | null, access: string | null): void;
|
|
243
255
|
saveState(): Promise<void>;
|
|
244
256
|
/**
|
|
245
257
|
* Ingest a record change into the processor.
|
|
@@ -464,6 +476,18 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
464
476
|
* still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
|
|
465
477
|
*/
|
|
466
478
|
isCold(hash: string): boolean;
|
|
479
|
+
/**
|
|
480
|
+
* Walk a hydrated record's fields and append any EMBEDDED child records to
|
|
481
|
+
* `batch` (recursing for nested related fields). An embedded child is a
|
|
482
|
+
* value that is itself a record — a non-null object whose `id` is a
|
|
483
|
+
* `RecordId` — or an array of such records (one-to-many vs one-to-one). A
|
|
484
|
+
* bare `RecordId` (a foreign-key reference) or any other value is skipped,
|
|
485
|
+
* so this never mistakes a FK column for an embedded body. Children are
|
|
486
|
+
* keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
|
|
487
|
+
* to their table's real columns (which strips the alias/related fields).
|
|
488
|
+
* `seen` dedupes within the batch.
|
|
489
|
+
*/
|
|
490
|
+
private collectEmbeddedChildren;
|
|
467
491
|
/**
|
|
468
492
|
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
469
493
|
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
@@ -651,6 +675,19 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
651
675
|
private subscribeToReconnect;
|
|
652
676
|
private startRefLiveQueries;
|
|
653
677
|
private handleRemoteListRefChange;
|
|
678
|
+
/**
|
|
679
|
+
* Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
|
|
680
|
+
* `parent` set) for a `.related()` query. Unlike primary rows, child rows
|
|
681
|
+
* must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
|
|
682
|
+
* keep the child BODY fresh in the local cache so the in-browser SSP's
|
|
683
|
+
* subquery-table dependency re-materializes the parent view.
|
|
684
|
+
*
|
|
685
|
+
* CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
|
|
686
|
+
* no-op: a child leaving this query's set must not delete a body another
|
|
687
|
+
* query may still show (see `syncSubqueryChildren` deletion-safety note);
|
|
688
|
+
* a genuine record delete propagates via the normal delete path.
|
|
689
|
+
*/
|
|
690
|
+
private handleRemoteSubqueryChange;
|
|
654
691
|
/**
|
|
655
692
|
* Enqueues a 'down' event (from remote to local) for processing.
|
|
656
693
|
* @param event The DownEvent to enqueue.
|
|
@@ -687,6 +724,26 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
687
724
|
enqueueMutation(mutations: UpEvent[]): Promise<void>;
|
|
688
725
|
private registerQuery;
|
|
689
726
|
private createRemoteQuery;
|
|
727
|
+
/**
|
|
728
|
+
* Sync the BODIES of a `.related()` query's subquery child rows into the
|
|
729
|
+
* local cache, separately from the primary window array. The SSP writes
|
|
730
|
+
* each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
|
|
731
|
+
* `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
|
|
732
|
+
* nesting depth). We diff against the in-memory `subqueryRemoteArray` and
|
|
733
|
+
* fetch added/updated bodies through the SyncEngine — which `saveBatch`s
|
|
734
|
+
* them into the local DB AND the in-browser SSP, whose subquery-table
|
|
735
|
+
* dependency then re-materializes the parent view (no explicit notify).
|
|
736
|
+
*
|
|
737
|
+
* Deletion safety: we pass `removed: []` deliberately. A child body can be
|
|
738
|
+
* shared by other queries; letting `handleRemovedRecords` delete one that
|
|
739
|
+
* merely left THIS query's child set would clobber data another query still
|
|
740
|
+
* shows. Genuine record deletes flow through the normal delete path; a
|
|
741
|
+
* lingering orphan body is invisible (the correlated WHERE stops matching).
|
|
742
|
+
*
|
|
743
|
+
* Kept off `runSyncForQuery` on purpose so child fetches never flip the
|
|
744
|
+
* query to `fetching` or skew its DevTools timings.
|
|
745
|
+
*/
|
|
746
|
+
private syncSubqueryChildren;
|
|
690
747
|
heartbeatQuery(queryHash: string): Promise<void>;
|
|
691
748
|
private cleanupQuery;
|
|
692
749
|
}
|
|
@@ -713,6 +770,13 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
713
770
|
token: string | null;
|
|
714
771
|
currentUser: any | null;
|
|
715
772
|
isAuthenticated: boolean;
|
|
773
|
+
/**
|
|
774
|
+
* The record-access method name for the current session (e.g. `"account"`),
|
|
775
|
+
* derived from the token's `AC` claim. Consumed by the in-browser SSP's
|
|
776
|
+
* permission injection so `$access`-gated table predicates resolve locally,
|
|
777
|
+
* mirroring the server's `$access`. Null when logged out.
|
|
778
|
+
*/
|
|
779
|
+
access: string | null;
|
|
716
780
|
isLoading: boolean;
|
|
717
781
|
private events;
|
|
718
782
|
get eventSystem(): AuthEventSystem;
|
|
@@ -734,6 +798,9 @@ declare class AuthService<S extends SchemaStructure> {
|
|
|
734
798
|
*/
|
|
735
799
|
signOut(): Promise<void>;
|
|
736
800
|
private setSession;
|
|
801
|
+
/** Fallback when the token carries no `AC` claim: if the schema defines
|
|
802
|
+
* exactly one record-access method, assume the session used it. */
|
|
803
|
+
private defaultAccessName;
|
|
737
804
|
signUp<Name extends keyof S['access'] & string>(accessName: Name, params: ExtractAccessParams<S, Name, 'signup'>): Promise<void>;
|
|
738
805
|
signIn<Name extends keyof S['access'] & string>(accessName: Name, params: ExtractAccessParams<S, Name, 'signIn'>): Promise<void>;
|
|
739
806
|
}
|
package/dist/index.js
CHANGED
|
@@ -747,6 +747,37 @@ var DataModule = class {
|
|
|
747
747
|
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
748
748
|
}
|
|
749
749
|
/**
|
|
750
|
+
* Walk a hydrated record's fields and append any EMBEDDED child records to
|
|
751
|
+
* `batch` (recursing for nested related fields). An embedded child is a
|
|
752
|
+
* value that is itself a record — a non-null object whose `id` is a
|
|
753
|
+
* `RecordId` — or an array of such records (one-to-many vs one-to-one). A
|
|
754
|
+
* bare `RecordId` (a foreign-key reference) or any other value is skipped,
|
|
755
|
+
* so this never mistakes a FK column for an embedded body. Children are
|
|
756
|
+
* keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
|
|
757
|
+
* to their table's real columns (which strips the alias/related fields).
|
|
758
|
+
* `seen` dedupes within the batch.
|
|
759
|
+
*/
|
|
760
|
+
collectEmbeddedChildren(record, batch, seen) {
|
|
761
|
+
const isEmbeddedRecord = (v) => !!v && typeof v === "object" && !(v instanceof RecordId) && v.id instanceof RecordId;
|
|
762
|
+
for (const value of Object.values(record)) {
|
|
763
|
+
const children = Array.isArray(value) ? value.filter(isEmbeddedRecord) : isEmbeddedRecord(value) ? [value] : [];
|
|
764
|
+
for (const child of children) {
|
|
765
|
+
const key = encodeRecordId(child.id);
|
|
766
|
+
if (seen.has(key)) continue;
|
|
767
|
+
seen.add(key);
|
|
768
|
+
this.collectEmbeddedChildren(child, batch, seen);
|
|
769
|
+
const table = child.id.table.toString();
|
|
770
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
771
|
+
batch.push({
|
|
772
|
+
table,
|
|
773
|
+
op: "CREATE",
|
|
774
|
+
record: tableSchema ? cleanRecord(tableSchema.columns, child) : child,
|
|
775
|
+
version: child._00_rv || 1
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
750
781
|
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
751
782
|
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
752
783
|
* registration proceeds in the background. Ingests with versions (`_00_rv`) so the
|
|
@@ -766,6 +797,8 @@ var DataModule = class {
|
|
|
766
797
|
record,
|
|
767
798
|
version: record._00_rv || 1
|
|
768
799
|
}));
|
|
800
|
+
const seen = new Set(rows.map((r) => encodeRecordId(r.id)));
|
|
801
|
+
for (const record of rows) this.collectEmbeddedChildren(record, batch, seen);
|
|
769
802
|
await this.cache.saveBatch(batch);
|
|
770
803
|
queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
|
|
771
804
|
queryState.records = await this.materializeRecords(queryState);
|
|
@@ -2244,6 +2277,21 @@ function buildListRefSelect(table) {
|
|
|
2244
2277
|
return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
|
|
2245
2278
|
}
|
|
2246
2279
|
/**
|
|
2280
|
+
* Build the SurrealQL select for a query's SUBQUERY child edges — the
|
|
2281
|
+
* mirror of {@link buildListRefSelect}. `.related()` queries register a
|
|
2282
|
+
* correlated subquery; the SSP materializes each matched child as a
|
|
2283
|
+
* `_00_list_ref` edge tagged with `parent`/`parent_rel` (see
|
|
2284
|
+
* `apps/ssp` edge writer). `parent IS NONE` (the primary select) drops
|
|
2285
|
+
* these, so their bodies never reach the local cache and a cold-reload
|
|
2286
|
+
* re-materialization of the correlated surql yields empty related
|
|
2287
|
+
* fields. This `parent IS NOT NONE` variant pulls the child `out`+`version`
|
|
2288
|
+
* pairs (any nesting depth) so we can sync their bodies into the local
|
|
2289
|
+
* store SEPARATELY from the primary window array.
|
|
2290
|
+
*/
|
|
2291
|
+
function buildSubqueryListRefSelect(table) {
|
|
2292
|
+
return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2247
2295
|
* Resolve the effective list-ref poll interval. Negative or zero
|
|
2248
2296
|
* values fall back to the default — accepting them would either
|
|
2249
2297
|
* disable polling silently or busy-loop the event loop.
|
|
@@ -2743,6 +2791,13 @@ var Sp00kySync = class {
|
|
|
2743
2791
|
Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
|
|
2744
2792
|
}, "syncQuery failed during poll");
|
|
2745
2793
|
}
|
|
2794
|
+
await this.syncSubqueryChildren(queryHash).catch((err) => {
|
|
2795
|
+
this.logger.info({
|
|
2796
|
+
err: err?.message ?? err,
|
|
2797
|
+
queryHash,
|
|
2798
|
+
Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
|
|
2799
|
+
}, "Subquery child sync failed during poll");
|
|
2800
|
+
});
|
|
2746
2801
|
if (removedIds.length > 0) try {
|
|
2747
2802
|
await this.dataModule.notifyQuerySynced(queryHash);
|
|
2748
2803
|
} catch (err) {
|
|
@@ -2827,7 +2882,15 @@ var Sp00kySync = class {
|
|
|
2827
2882
|
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
2828
2883
|
}, "Live update received");
|
|
2829
2884
|
if (message.action === "KILLED") return;
|
|
2830
|
-
if (message.value.parent != null)
|
|
2885
|
+
if (message.value.parent != null) {
|
|
2886
|
+
this.handleRemoteSubqueryChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
|
|
2887
|
+
this.logger.error({
|
|
2888
|
+
err,
|
|
2889
|
+
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
2890
|
+
}, "Error handling remote subquery change");
|
|
2891
|
+
});
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2831
2894
|
this.handleRemoteListRefChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
|
|
2832
2895
|
this.logger.error({
|
|
2833
2896
|
err,
|
|
@@ -2861,6 +2924,41 @@ var Sp00kySync = class {
|
|
|
2861
2924
|
await this.runSyncForQuery(hash, diff);
|
|
2862
2925
|
}
|
|
2863
2926
|
/**
|
|
2927
|
+
* Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
|
|
2928
|
+
* `parent` set) for a `.related()` query. Unlike primary rows, child rows
|
|
2929
|
+
* must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
|
|
2930
|
+
* keep the child BODY fresh in the local cache so the in-browser SSP's
|
|
2931
|
+
* subquery-table dependency re-materializes the parent view.
|
|
2932
|
+
*
|
|
2933
|
+
* CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
|
|
2934
|
+
* no-op: a child leaving this query's set must not delete a body another
|
|
2935
|
+
* query may still show (see `syncSubqueryChildren` deletion-safety note);
|
|
2936
|
+
* a genuine record delete propagates via the normal delete path.
|
|
2937
|
+
*/
|
|
2938
|
+
async handleRemoteSubqueryChange(action, queryId, childId, version) {
|
|
2939
|
+
this.lastLiveEventAt = Date.now();
|
|
2940
|
+
this.listRefIdleStreak = 0;
|
|
2941
|
+
if (action === "DELETE") return;
|
|
2942
|
+
const existing = this.dataModule.getQueryById(queryId);
|
|
2943
|
+
if (!existing) return;
|
|
2944
|
+
const item = {
|
|
2945
|
+
id: childId,
|
|
2946
|
+
version
|
|
2947
|
+
};
|
|
2948
|
+
await this.syncEngine.syncRecords(action === "CREATE" ? {
|
|
2949
|
+
added: [item],
|
|
2950
|
+
updated: [],
|
|
2951
|
+
removed: []
|
|
2952
|
+
} : {
|
|
2953
|
+
added: [],
|
|
2954
|
+
updated: [item],
|
|
2955
|
+
removed: []
|
|
2956
|
+
});
|
|
2957
|
+
const key = encodeRecordId(childId);
|
|
2958
|
+
const prev = existing.config.subqueryRemoteArray ?? [];
|
|
2959
|
+
existing.config.subqueryRemoteArray = [...prev.filter(([id]) => id !== key), [key, version]];
|
|
2960
|
+
}
|
|
2961
|
+
/**
|
|
2864
2962
|
* Enqueues a 'down' event (from remote to local) for processing.
|
|
2865
2963
|
* @param event The DownEvent to enqueue.
|
|
2866
2964
|
*/
|
|
@@ -3083,6 +3181,49 @@ var Sp00kySync = class {
|
|
|
3083
3181
|
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
3084
3182
|
}, "createdRemoteQuery");
|
|
3085
3183
|
if (array) await this.dataModule.updateQueryRemoteArray(queryHash, array);
|
|
3184
|
+
await this.syncSubqueryChildren(queryHash).catch((err) => {
|
|
3185
|
+
this.logger.info({
|
|
3186
|
+
err: err?.message ?? err,
|
|
3187
|
+
queryHash,
|
|
3188
|
+
Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
|
|
3189
|
+
}, "Subquery child sync failed during registration; poll will retry");
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
/**
|
|
3193
|
+
* Sync the BODIES of a `.related()` query's subquery child rows into the
|
|
3194
|
+
* local cache, separately from the primary window array. The SSP writes
|
|
3195
|
+
* each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
|
|
3196
|
+
* `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
|
|
3197
|
+
* nesting depth). We diff against the in-memory `subqueryRemoteArray` and
|
|
3198
|
+
* fetch added/updated bodies through the SyncEngine — which `saveBatch`s
|
|
3199
|
+
* them into the local DB AND the in-browser SSP, whose subquery-table
|
|
3200
|
+
* dependency then re-materializes the parent view (no explicit notify).
|
|
3201
|
+
*
|
|
3202
|
+
* Deletion safety: we pass `removed: []` deliberately. A child body can be
|
|
3203
|
+
* shared by other queries; letting `handleRemovedRecords` delete one that
|
|
3204
|
+
* merely left THIS query's child set would clobber data another query still
|
|
3205
|
+
* shows. Genuine record deletes flow through the normal delete path; a
|
|
3206
|
+
* lingering orphan body is invisible (the correlated WHERE stops matching).
|
|
3207
|
+
*
|
|
3208
|
+
* Kept off `runSyncForQuery` on purpose so child fetches never flip the
|
|
3209
|
+
* query to `fetching` or skew its DevTools timings.
|
|
3210
|
+
*/
|
|
3211
|
+
async syncSubqueryChildren(queryHash) {
|
|
3212
|
+
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
3213
|
+
if (!queryState) return;
|
|
3214
|
+
const listRefTbl = this.listRefTable();
|
|
3215
|
+
const [items] = await this.remote.query(buildSubqueryListRefSelect(listRefTbl), { in: queryState.config.id });
|
|
3216
|
+
if (!Array.isArray(items)) return;
|
|
3217
|
+
const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
3218
|
+
const prev = queryState.config.subqueryRemoteArray ?? [];
|
|
3219
|
+
if (recordVersionArraysEqual(fresh, prev)) return;
|
|
3220
|
+
const diff = diffRecordVersionArray(prev, fresh);
|
|
3221
|
+
if (diff.added.length > 0 || diff.updated.length > 0) await this.syncEngine.syncRecords({
|
|
3222
|
+
added: diff.added,
|
|
3223
|
+
updated: diff.updated,
|
|
3224
|
+
removed: []
|
|
3225
|
+
});
|
|
3226
|
+
queryState.config.subqueryRemoteArray = fresh;
|
|
3086
3227
|
}
|
|
3087
3228
|
async heartbeatQuery(queryHash) {
|
|
3088
3229
|
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
@@ -3171,8 +3312,8 @@ function parseBackendInfo(raw) {
|
|
|
3171
3312
|
|
|
3172
3313
|
//#endregion
|
|
3173
3314
|
//#region src/modules/devtools/index.ts
|
|
3174
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
3175
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
3315
|
+
const CORE_VERSION = "0.0.1-canary.76";
|
|
3316
|
+
const WASM_VERSION = "0.0.1-canary.76";
|
|
3176
3317
|
const SURREAL_VERSION = "3.0.3";
|
|
3177
3318
|
var DevToolsService = class {
|
|
3178
3319
|
eventsHistory = [];
|
|
@@ -3463,10 +3604,38 @@ var DevToolsService = class {
|
|
|
3463
3604
|
|
|
3464
3605
|
//#endregion
|
|
3465
3606
|
//#region src/modules/auth/index.ts
|
|
3607
|
+
/**
|
|
3608
|
+
* Read the `AC` (access-method name) claim from a SurrealDB record-access
|
|
3609
|
+
* JWT without verifying it — we only need the claim, the server enforces the
|
|
3610
|
+
* token. Returns null on any malformed input. The in-browser SSP needs this
|
|
3611
|
+
* to resolve `$access` in table permission predicates (mirrors the session's
|
|
3612
|
+
* `$access` that the server's `fn::query::register` reads).
|
|
3613
|
+
*/
|
|
3614
|
+
function decodeAccessFromToken(token) {
|
|
3615
|
+
try {
|
|
3616
|
+
const payload = token.split(".")[1];
|
|
3617
|
+
if (!payload) return null;
|
|
3618
|
+
let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
3619
|
+
b64 += "=".repeat((4 - b64.length % 4) % 4);
|
|
3620
|
+
const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
3621
|
+
const claims = JSON.parse(json);
|
|
3622
|
+
const ac = claims.AC ?? claims.ac;
|
|
3623
|
+
return typeof ac === "string" ? ac : null;
|
|
3624
|
+
} catch {
|
|
3625
|
+
return null;
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3466
3628
|
var AuthService = class {
|
|
3467
3629
|
token = null;
|
|
3468
3630
|
currentUser = null;
|
|
3469
3631
|
isAuthenticated = false;
|
|
3632
|
+
/**
|
|
3633
|
+
* The record-access method name for the current session (e.g. `"account"`),
|
|
3634
|
+
* derived from the token's `AC` claim. Consumed by the in-browser SSP's
|
|
3635
|
+
* permission injection so `$access`-gated table predicates resolve locally,
|
|
3636
|
+
* mirroring the server's `$access`. Null when logged out.
|
|
3637
|
+
*/
|
|
3638
|
+
access = null;
|
|
3470
3639
|
isLoading = true;
|
|
3471
3640
|
events = createAuthEventSystem();
|
|
3472
3641
|
get eventSystem() {
|
|
@@ -3559,6 +3728,7 @@ var AuthService = class {
|
|
|
3559
3728
|
this.token = null;
|
|
3560
3729
|
this.currentUser = null;
|
|
3561
3730
|
this.isAuthenticated = false;
|
|
3731
|
+
this.access = null;
|
|
3562
3732
|
await this.persistenceClient.remove("sp00ky_auth_token");
|
|
3563
3733
|
try {
|
|
3564
3734
|
await this.remote.getClient().invalidate();
|
|
@@ -3569,9 +3739,16 @@ var AuthService = class {
|
|
|
3569
3739
|
this.token = token;
|
|
3570
3740
|
this.currentUser = user;
|
|
3571
3741
|
this.isAuthenticated = true;
|
|
3742
|
+
this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
|
|
3572
3743
|
await this.persistenceClient.set("sp00ky_auth_token", token);
|
|
3573
3744
|
this.notifyListeners();
|
|
3574
3745
|
}
|
|
3746
|
+
/** Fallback when the token carries no `AC` claim: if the schema defines
|
|
3747
|
+
* exactly one record-access method, assume the session used it. */
|
|
3748
|
+
defaultAccessName() {
|
|
3749
|
+
const names = Object.keys(this.schema.access ?? {});
|
|
3750
|
+
return names.length === 1 ? names[0] : null;
|
|
3751
|
+
}
|
|
3575
3752
|
async signUp(accessName, params) {
|
|
3576
3753
|
const def = this.getAccessDefinition(accessName);
|
|
3577
3754
|
if (!def) throw new Error(`Access definition '${accessName}' not found`);
|
|
@@ -3617,6 +3794,10 @@ var StreamProcessorService = class {
|
|
|
3617
3794
|
receivers = [];
|
|
3618
3795
|
batching = false;
|
|
3619
3796
|
batchBuffer = /* @__PURE__ */ new Map();
|
|
3797
|
+
sessionAuth = {
|
|
3798
|
+
authId: "",
|
|
3799
|
+
access: ""
|
|
3800
|
+
};
|
|
3620
3801
|
constructor(events, db, persistenceClient, logger) {
|
|
3621
3802
|
this.events = events;
|
|
3622
3803
|
this.db = db;
|
|
@@ -3757,6 +3938,27 @@ var StreamProcessorService = class {
|
|
|
3757
3938
|
Category: "sp00ky-client::StreamProcessorService::setPermissions"
|
|
3758
3939
|
}, "Seeded table permissions");
|
|
3759
3940
|
}
|
|
3941
|
+
/**
|
|
3942
|
+
* Set the current session's auth identity for permission injection,
|
|
3943
|
+
* mirroring the server's `fn::query::register`
|
|
3944
|
+
* (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
|
|
3945
|
+
* Stored as strings (empty when logged out) and applied to every
|
|
3946
|
+
* `register_view` in {@link registerQueryPlan}. Must be set before a
|
|
3947
|
+
* `$auth`-gated query registers (and re-set on auth state changes), or the
|
|
3948
|
+
* in-browser SSP's `permission_inject` rejects it with
|
|
3949
|
+
* "requires $auth but registration params lack it".
|
|
3950
|
+
*/
|
|
3951
|
+
setSessionAuth(authId, access) {
|
|
3952
|
+
this.sessionAuth = {
|
|
3953
|
+
authId: authId ?? "",
|
|
3954
|
+
access: access ?? ""
|
|
3955
|
+
};
|
|
3956
|
+
this.logger.debug({
|
|
3957
|
+
authId: this.sessionAuth.authId,
|
|
3958
|
+
access: this.sessionAuth.access,
|
|
3959
|
+
Category: "sp00ky-client::StreamProcessorService::setSessionAuth"
|
|
3960
|
+
}, "Session auth context updated");
|
|
3961
|
+
}
|
|
3760
3962
|
async saveState() {
|
|
3761
3963
|
if (!this.processor) return;
|
|
3762
3964
|
try {
|
|
@@ -3841,11 +4043,15 @@ var StreamProcessorService = class {
|
|
|
3841
4043
|
Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
|
|
3842
4044
|
}, "Registering query plan");
|
|
3843
4045
|
try {
|
|
3844
|
-
const
|
|
4046
|
+
const paramsWithAuth = {
|
|
4047
|
+
...this.normalizeValue(queryPlan.params),
|
|
4048
|
+
auth: { id: this.sessionAuth.authId },
|
|
4049
|
+
access: this.sessionAuth.access
|
|
4050
|
+
};
|
|
3845
4051
|
const initialUpdate = this.processor.register_view({
|
|
3846
4052
|
id: queryPlan.queryHash,
|
|
3847
4053
|
surql: queryPlan.surql,
|
|
3848
|
-
params:
|
|
4054
|
+
params: paramsWithAuth,
|
|
3849
4055
|
clientId: "local",
|
|
3850
4056
|
ttl: queryPlan.ttl.toString(),
|
|
3851
4057
|
lastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -5047,6 +5253,7 @@ var Sp00kyClient = class {
|
|
|
5047
5253
|
}, "DataModule initialized");
|
|
5048
5254
|
this.auth.subscribe(async (userId) => {
|
|
5049
5255
|
this.dataModule.setCurrentUserId(userId);
|
|
5256
|
+
this.streamProcessor.setSessionAuth(this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null, this.auth.access);
|
|
5050
5257
|
const next = await this.fetchSessionId();
|
|
5051
5258
|
this.dataModule.setSessionId(next);
|
|
5052
5259
|
this.crdtManager.setSessionId(next);
|
package/dist/types.d.ts
CHANGED
|
@@ -327,6 +327,14 @@ interface QueryConfig {
|
|
|
327
327
|
localArray: RecordVersionArray;
|
|
328
328
|
/** The version array representing the remote (server) state of results. */
|
|
329
329
|
remoteArray: RecordVersionArray;
|
|
330
|
+
/**
|
|
331
|
+
* In-memory only (never persisted to `_00_query`): version array of the
|
|
332
|
+
* subquery CHILD rows pulled via `parent IS NOT NONE` edges, so the
|
|
333
|
+
* child-body sync is idempotent across polls. Kept separate from
|
|
334
|
+
* `remoteArray` so related child rows never enter the primary window /
|
|
335
|
+
* `rowCount` / `localArray`.
|
|
336
|
+
*/
|
|
337
|
+
subqueryRemoteArray?: RecordVersionArray;
|
|
330
338
|
/** Time-To-Live for this query. */
|
|
331
339
|
ttl: QueryTimeToLive;
|
|
332
340
|
/** Timestamp when the query was last accessed/active. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.76",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
62
|
+
"@spooky-sync/query-builder": "0.0.1-canary.76",
|
|
63
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.76",
|
|
64
64
|
"@surrealdb/wasm": "^3.0.3",
|
|
65
65
|
"fast-json-patch": "^3.1.1",
|
|
66
66
|
"loro-crdt": "^1.5.6",
|
|
@@ -35,11 +35,41 @@ type ExtractAccessParams<
|
|
|
35
35
|
}>
|
|
36
36
|
: never;
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Read the `AC` (access-method name) claim from a SurrealDB record-access
|
|
40
|
+
* JWT without verifying it — we only need the claim, the server enforces the
|
|
41
|
+
* token. Returns null on any malformed input. The in-browser SSP needs this
|
|
42
|
+
* to resolve `$access` in table permission predicates (mirrors the session's
|
|
43
|
+
* `$access` that the server's `fn::query::register` reads).
|
|
44
|
+
*/
|
|
45
|
+
function decodeAccessFromToken(token: string): string | null {
|
|
46
|
+
try {
|
|
47
|
+
const payload = token.split('.')[1];
|
|
48
|
+
if (!payload) return null;
|
|
49
|
+
let b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
|
50
|
+
b64 += '='.repeat((4 - (b64.length % 4)) % 4);
|
|
51
|
+
const json =
|
|
52
|
+
typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
|
|
53
|
+
const claims = JSON.parse(json) as Record<string, unknown>;
|
|
54
|
+
const ac = claims.AC ?? claims.ac;
|
|
55
|
+
return typeof ac === 'string' ? ac : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
export class AuthService<S extends SchemaStructure> {
|
|
39
62
|
// State
|
|
40
63
|
public token: string | null = null;
|
|
41
64
|
public currentUser: any | null = null;
|
|
42
65
|
public isAuthenticated: boolean = false;
|
|
66
|
+
/**
|
|
67
|
+
* The record-access method name for the current session (e.g. `"account"`),
|
|
68
|
+
* derived from the token's `AC` claim. Consumed by the in-browser SSP's
|
|
69
|
+
* permission injection so `$access`-gated table predicates resolve locally,
|
|
70
|
+
* mirroring the server's `$access`. Null when logged out.
|
|
71
|
+
*/
|
|
72
|
+
public access: string | null = null;
|
|
43
73
|
public isLoading: boolean = true;
|
|
44
74
|
|
|
45
75
|
private events = createAuthEventSystem();
|
|
@@ -167,6 +197,7 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
167
197
|
this.token = null;
|
|
168
198
|
this.currentUser = null;
|
|
169
199
|
this.isAuthenticated = false;
|
|
200
|
+
this.access = null;
|
|
170
201
|
|
|
171
202
|
await this.persistenceClient.remove('sp00ky_auth_token');
|
|
172
203
|
|
|
@@ -183,10 +214,21 @@ export class AuthService<S extends SchemaStructure> {
|
|
|
183
214
|
this.token = token;
|
|
184
215
|
this.currentUser = user;
|
|
185
216
|
this.isAuthenticated = true;
|
|
217
|
+
// Resolve the access-method name (e.g. "account") for in-browser SSP
|
|
218
|
+
// permission injection. Prefer the token's `AC` claim; fall back to the
|
|
219
|
+
// schema's sole record-access method if the claim is absent.
|
|
220
|
+
this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
|
|
186
221
|
await this.persistenceClient.set('sp00ky_auth_token', token);
|
|
187
222
|
this.notifyListeners();
|
|
188
223
|
}
|
|
189
224
|
|
|
225
|
+
/** Fallback when the token carries no `AC` claim: if the schema defines
|
|
226
|
+
* exactly one record-access method, assume the session used it. */
|
|
227
|
+
private defaultAccessName(): string | null {
|
|
228
|
+
const names = Object.keys(this.schema.access ?? {});
|
|
229
|
+
return names.length === 1 ? names[0] : null;
|
|
230
|
+
}
|
|
231
|
+
|
|
190
232
|
async signUp<Name extends keyof S['access'] & string>(
|
|
191
233
|
accessName: Name,
|
|
192
234
|
params: ExtractAccessParams<S, Name, 'signup'>
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
withRetry,
|
|
36
36
|
surql,
|
|
37
37
|
parseParams,
|
|
38
|
+
cleanRecord,
|
|
38
39
|
extractTablePart,
|
|
39
40
|
generateId,
|
|
40
41
|
} from '../../utils/index';
|
|
@@ -606,6 +607,55 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
606
607
|
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
607
608
|
}
|
|
608
609
|
|
|
610
|
+
/**
|
|
611
|
+
* Walk a hydrated record's fields and append any EMBEDDED child records to
|
|
612
|
+
* `batch` (recursing for nested related fields). An embedded child is a
|
|
613
|
+
* value that is itself a record — a non-null object whose `id` is a
|
|
614
|
+
* `RecordId` — or an array of such records (one-to-many vs one-to-one). A
|
|
615
|
+
* bare `RecordId` (a foreign-key reference) or any other value is skipped,
|
|
616
|
+
* so this never mistakes a FK column for an embedded body. Children are
|
|
617
|
+
* keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
|
|
618
|
+
* to their table's real columns (which strips the alias/related fields).
|
|
619
|
+
* `seen` dedupes within the batch.
|
|
620
|
+
*/
|
|
621
|
+
private collectEmbeddedChildren(
|
|
622
|
+
record: Record<string, any>,
|
|
623
|
+
batch: CacheRecord[],
|
|
624
|
+
seen: Set<string>
|
|
625
|
+
): void {
|
|
626
|
+
const isEmbeddedRecord = (v: unknown): v is RecordWithId =>
|
|
627
|
+
!!v &&
|
|
628
|
+
typeof v === 'object' &&
|
|
629
|
+
!(v instanceof RecordId) &&
|
|
630
|
+
(v as { id?: unknown }).id instanceof RecordId;
|
|
631
|
+
|
|
632
|
+
for (const value of Object.values(record)) {
|
|
633
|
+
const children = Array.isArray(value)
|
|
634
|
+
? value.filter(isEmbeddedRecord)
|
|
635
|
+
: isEmbeddedRecord(value)
|
|
636
|
+
? [value]
|
|
637
|
+
: [];
|
|
638
|
+
for (const child of children) {
|
|
639
|
+
const key = encodeRecordId(child.id);
|
|
640
|
+
if (seen.has(key)) continue;
|
|
641
|
+
seen.add(key);
|
|
642
|
+
// Recurse FIRST so nested grandchildren are captured before `cleanRecord`
|
|
643
|
+
// strips this child's alias fields.
|
|
644
|
+
this.collectEmbeddedChildren(child, batch, seen);
|
|
645
|
+
const table = child.id.table.toString();
|
|
646
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
647
|
+
batch.push({
|
|
648
|
+
table,
|
|
649
|
+
op: 'CREATE',
|
|
650
|
+
record: (tableSchema
|
|
651
|
+
? cleanRecord(tableSchema.columns, child)
|
|
652
|
+
: child) as RecordWithId,
|
|
653
|
+
version: (child._00_rv as number) || 1,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
609
659
|
/**
|
|
610
660
|
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
611
661
|
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
@@ -627,6 +677,20 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
627
677
|
record,
|
|
628
678
|
version: (record._00_rv as number) || 1,
|
|
629
679
|
}));
|
|
680
|
+
|
|
681
|
+
// Flicker-free related data on cold first paint: a `.related()` query's
|
|
682
|
+
// rows arrive with their child rows EMBEDDED (the correlated subquery,
|
|
683
|
+
// e.g. `(SELECT * FROM comment WHERE game=$parent.id) AS comments`). The
|
|
684
|
+
// primary batch above persists only the parent table, so the immediate
|
|
685
|
+
// `materializeRecords` below would re-run the correlated surql against a
|
|
686
|
+
// local DB with no child rows and overwrite the embedded result with
|
|
687
|
+
// empties. Extract those embedded children (any nesting depth) and
|
|
688
|
+
// persist them as their own records so the re-materialization finds them.
|
|
689
|
+
const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
|
|
690
|
+
for (const record of rows) {
|
|
691
|
+
this.collectEmbeddedChildren(record, batch, seen);
|
|
692
|
+
}
|
|
693
|
+
|
|
630
694
|
await this.cache.saveBatch(batch);
|
|
631
695
|
|
|
632
696
|
// Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -8,7 +8,9 @@ import type { RecordId, Uuid } from 'surrealdb';
|
|
|
8
8
|
import {
|
|
9
9
|
ArraySyncer,
|
|
10
10
|
buildListRefSelect,
|
|
11
|
+
buildSubqueryListRefSelect,
|
|
11
12
|
createDiffFromDbOp,
|
|
13
|
+
diffRecordVersionArray,
|
|
12
14
|
listRefPollDelayMs,
|
|
13
15
|
recordVersionArraysEqual,
|
|
14
16
|
resolveListRefPollInterval,
|
|
@@ -350,6 +352,15 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
350
352
|
'syncQuery failed during poll'
|
|
351
353
|
);
|
|
352
354
|
}
|
|
355
|
+
// Cross-session fallback for `.related()` child rows: the LIVE-permission
|
|
356
|
+
// gap can drop child-edge notifications, so converge their bodies on the
|
|
357
|
+
// poll too (idempotent — no-op when nothing changed).
|
|
358
|
+
await this.syncSubqueryChildren(queryHash).catch((err) => {
|
|
359
|
+
this.logger.info(
|
|
360
|
+
{ err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::refetchListRefForQuery' },
|
|
361
|
+
'Subquery child sync failed during poll'
|
|
362
|
+
);
|
|
363
|
+
});
|
|
353
364
|
// A REMOVAL needs no record fetch, so unlike the added-row path it doesn't
|
|
354
365
|
// get a re-render from the SSP stream on this code path reliably (and the
|
|
355
366
|
// non-windowed window-0 query re-queries the local DB rather than the id-set).
|
|
@@ -463,12 +474,26 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
463
474
|
'Live update received'
|
|
464
475
|
);
|
|
465
476
|
if (message.action === 'KILLED') return;
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
|
|
477
|
+
// Subquery child edges (rows with `parent` set) are NOT primary window
|
|
478
|
+
// rows — the client's `RecordVersionArray` only tracks primary rows, so
|
|
479
|
+
// routing them through `handleRemoteListRefChange` would surface them as
|
|
480
|
+
// spurious "added" diffs and pollute the window. Instead route them to
|
|
481
|
+
// the dedicated child-body sync so `.related()` data stays realtime
|
|
482
|
+
// cross-session (the LIVE-permission gap otherwise leaves it to the poll).
|
|
483
|
+
if ((message.value as { parent?: unknown }).parent != null) {
|
|
484
|
+
this.handleRemoteSubqueryChange(
|
|
485
|
+
message.action,
|
|
486
|
+
message.value.in as RecordId<string>,
|
|
487
|
+
message.value.out as RecordId<string>,
|
|
488
|
+
message.value.version as number
|
|
489
|
+
).catch((err) => {
|
|
490
|
+
this.logger.error(
|
|
491
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::startRefLiveQueries' },
|
|
492
|
+
'Error handling remote subquery change'
|
|
493
|
+
);
|
|
494
|
+
});
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
472
497
|
this.handleRemoteListRefChange(
|
|
473
498
|
message.action,
|
|
474
499
|
message.value.in as RecordId<string>,
|
|
@@ -535,6 +560,46 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
535
560
|
await this.runSyncForQuery(hash, diff);
|
|
536
561
|
}
|
|
537
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
|
|
565
|
+
* `parent` set) for a `.related()` query. Unlike primary rows, child rows
|
|
566
|
+
* must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
|
|
567
|
+
* keep the child BODY fresh in the local cache so the in-browser SSP's
|
|
568
|
+
* subquery-table dependency re-materializes the parent view.
|
|
569
|
+
*
|
|
570
|
+
* CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
|
|
571
|
+
* no-op: a child leaving this query's set must not delete a body another
|
|
572
|
+
* query may still show (see `syncSubqueryChildren` deletion-safety note);
|
|
573
|
+
* a genuine record delete propagates via the normal delete path.
|
|
574
|
+
*/
|
|
575
|
+
private async handleRemoteSubqueryChange(
|
|
576
|
+
action: 'CREATE' | 'UPDATE' | 'DELETE',
|
|
577
|
+
queryId: RecordId,
|
|
578
|
+
childId: RecordId,
|
|
579
|
+
version: number
|
|
580
|
+
) {
|
|
581
|
+
this.lastLiveEventAt = Date.now();
|
|
582
|
+
this.listRefIdleStreak = 0;
|
|
583
|
+
|
|
584
|
+
if (action === 'DELETE') return;
|
|
585
|
+
|
|
586
|
+
const existing = this.dataModule.getQueryById(queryId);
|
|
587
|
+
if (!existing) return;
|
|
588
|
+
|
|
589
|
+
const item = { id: childId, version };
|
|
590
|
+
await this.syncEngine.syncRecords(
|
|
591
|
+
action === 'CREATE'
|
|
592
|
+
? { added: [item], updated: [], removed: [] }
|
|
593
|
+
: { added: [], updated: [item], removed: [] }
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
// Keep the in-memory child array in step so the poll's idempotent diff
|
|
597
|
+
// doesn't re-fetch this body on the next tick.
|
|
598
|
+
const key = encodeRecordId(childId);
|
|
599
|
+
const prev = existing.config.subqueryRemoteArray ?? [];
|
|
600
|
+
existing.config.subqueryRemoteArray = [...prev.filter(([id]) => id !== key), [key, version]];
|
|
601
|
+
}
|
|
602
|
+
|
|
538
603
|
/**
|
|
539
604
|
* Enqueues a 'down' event (from remote to local) for processing.
|
|
540
605
|
* @param event The DownEvent to enqueue.
|
|
@@ -860,6 +925,64 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
860
925
|
/// Incantation existed already
|
|
861
926
|
await this.dataModule.updateQueryRemoteArray(queryHash, array);
|
|
862
927
|
}
|
|
928
|
+
|
|
929
|
+
// Pull the bodies of any `.related()` subquery children into the local
|
|
930
|
+
// cache. The primary fetch above (`parent IS NONE`) tracks only window
|
|
931
|
+
// rows, so without this a cold-reload re-materialization of the
|
|
932
|
+
// correlated surql finds no child rows and related fields come back
|
|
933
|
+
// empty. Best-effort: never fail registration over it.
|
|
934
|
+
await this.syncSubqueryChildren(queryHash).catch((err) => {
|
|
935
|
+
this.logger.info(
|
|
936
|
+
{ err: (err as Error)?.message ?? err, queryHash, Category: 'sp00ky-client::Sp00kySync::createRemoteQuery' },
|
|
937
|
+
'Subquery child sync failed during registration; poll will retry'
|
|
938
|
+
);
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Sync the BODIES of a `.related()` query's subquery child rows into the
|
|
944
|
+
* local cache, separately from the primary window array. The SSP writes
|
|
945
|
+
* each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
|
|
946
|
+
* `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
|
|
947
|
+
* nesting depth). We diff against the in-memory `subqueryRemoteArray` and
|
|
948
|
+
* fetch added/updated bodies through the SyncEngine — which `saveBatch`s
|
|
949
|
+
* them into the local DB AND the in-browser SSP, whose subquery-table
|
|
950
|
+
* dependency then re-materializes the parent view (no explicit notify).
|
|
951
|
+
*
|
|
952
|
+
* Deletion safety: we pass `removed: []` deliberately. A child body can be
|
|
953
|
+
* shared by other queries; letting `handleRemovedRecords` delete one that
|
|
954
|
+
* merely left THIS query's child set would clobber data another query still
|
|
955
|
+
* shows. Genuine record deletes flow through the normal delete path; a
|
|
956
|
+
* lingering orphan body is invisible (the correlated WHERE stops matching).
|
|
957
|
+
*
|
|
958
|
+
* Kept off `runSyncForQuery` on purpose so child fetches never flip the
|
|
959
|
+
* query to `fetching` or skew its DevTools timings.
|
|
960
|
+
*/
|
|
961
|
+
private async syncSubqueryChildren(queryHash: string): Promise<void> {
|
|
962
|
+
const queryState = this.dataModule.getQueryByHash(queryHash);
|
|
963
|
+
if (!queryState) return;
|
|
964
|
+
|
|
965
|
+
const listRefTbl = this.listRefTable();
|
|
966
|
+
const [items] = await this.remote.query<[{ out: RecordId<string>; version: number }[]]>(
|
|
967
|
+
buildSubqueryListRefSelect(listRefTbl),
|
|
968
|
+
{ in: queryState.config.id }
|
|
969
|
+
);
|
|
970
|
+
if (!Array.isArray(items)) return;
|
|
971
|
+
|
|
972
|
+
const fresh: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
|
|
973
|
+
const prev = queryState.config.subqueryRemoteArray ?? [];
|
|
974
|
+
if (recordVersionArraysEqual(fresh, prev)) return; // idempotent: nothing new
|
|
975
|
+
|
|
976
|
+
const diff = diffRecordVersionArray(prev, fresh);
|
|
977
|
+
if (diff.added.length > 0 || diff.updated.length > 0) {
|
|
978
|
+
await this.syncEngine.syncRecords({
|
|
979
|
+
added: diff.added,
|
|
980
|
+
updated: diff.updated,
|
|
981
|
+
removed: [], // never delete child bodies here — see method doc
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
// In-memory only — child rows must never enter the persisted primary array.
|
|
985
|
+
queryState.config.subqueryRemoteArray = fresh;
|
|
863
986
|
}
|
|
864
987
|
|
|
865
988
|
public async heartbeatQuery(queryHash: string) {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
resolveListRefPollInterval,
|
|
9
9
|
DEFAULT_LIST_REF_POLL_INTERVAL_MS,
|
|
10
10
|
buildListRefSelect,
|
|
11
|
+
buildSubqueryListRefSelect,
|
|
11
12
|
nextPollDelayMs,
|
|
12
13
|
listRefPollDelayMs,
|
|
13
14
|
LIST_REF_POLL_MAX_INTERVAL_MS,
|
|
@@ -377,6 +378,35 @@ describe('buildListRefSelect', () => {
|
|
|
377
378
|
});
|
|
378
379
|
});
|
|
379
380
|
|
|
381
|
+
describe('buildSubqueryListRefSelect', () => {
|
|
382
|
+
it('substitutes the table name', () => {
|
|
383
|
+
expect(buildSubqueryListRefSelect('_00_list_ref')).toContain('FROM _00_list_ref ');
|
|
384
|
+
expect(buildSubqueryListRefSelect('_00_list_ref_user_abc')).toContain(
|
|
385
|
+
'FROM _00_list_ref_user_abc '
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('filters by the bound query id', () => {
|
|
390
|
+
expect(buildSubqueryListRefSelect('_00_list_ref')).toContain('WHERE in = $in');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('selects ONLY subquery child rows via parent IS NOT NONE', () => {
|
|
394
|
+
// The mirror of `buildListRefSelect`'s `parent IS NONE`: the SSP writes
|
|
395
|
+
// `.related()` child rows into list_ref tagged with `parent`/`parent_rel`;
|
|
396
|
+
// this select pulls exactly those so their bodies can be synced into the
|
|
397
|
+
// local cache (separately from the primary window) and the correlated
|
|
398
|
+
// surql re-materializes with related data on a cold reload.
|
|
399
|
+
const sql = buildSubqueryListRefSelect('_00_list_ref');
|
|
400
|
+
expect(sql).toContain('parent IS NOT NONE');
|
|
401
|
+
expect(sql).not.toContain('parent IS NONE ');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('selects only `out` and `version`', () => {
|
|
405
|
+
const sql = buildSubqueryListRefSelect('_00_list_ref');
|
|
406
|
+
expect(sql.startsWith('SELECT out, version FROM ')).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
380
410
|
describe('nextPollDelayMs', () => {
|
|
381
411
|
it('returns the base interval when LIVE has never delivered', () => {
|
|
382
412
|
expect(
|
|
@@ -188,6 +188,22 @@ export function buildListRefSelect(table: string): string {
|
|
|
188
188
|
return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Build the SurrealQL select for a query's SUBQUERY child edges — the
|
|
193
|
+
* mirror of {@link buildListRefSelect}. `.related()` queries register a
|
|
194
|
+
* correlated subquery; the SSP materializes each matched child as a
|
|
195
|
+
* `_00_list_ref` edge tagged with `parent`/`parent_rel` (see
|
|
196
|
+
* `apps/ssp` edge writer). `parent IS NONE` (the primary select) drops
|
|
197
|
+
* these, so their bodies never reach the local cache and a cold-reload
|
|
198
|
+
* re-materialization of the correlated surql yields empty related
|
|
199
|
+
* fields. This `parent IS NOT NONE` variant pulls the child `out`+`version`
|
|
200
|
+
* pairs (any nesting depth) so we can sync their bodies into the local
|
|
201
|
+
* store SEPARATELY from the primary window array.
|
|
202
|
+
*/
|
|
203
|
+
export function buildSubqueryListRefSelect(table: string): string {
|
|
204
|
+
return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
/**
|
|
192
208
|
* Resolve the effective list-ref poll interval. Negative or zero
|
|
193
209
|
* values fall back to the default — accepting them would either
|
|
@@ -68,6 +68,13 @@ export class StreamProcessorService {
|
|
|
68
68
|
// query, so the UI updates once after the whole batch rather than row-by-row.
|
|
69
69
|
private batching = false;
|
|
70
70
|
private batchBuffer: Map<string, StreamUpdate> = new Map();
|
|
71
|
+
// Current session's auth identity, injected into every `register_view`'s
|
|
72
|
+
// params so the in-browser SSP can resolve `$auth`/`$access` in table
|
|
73
|
+
// permission predicates (mirrors the server's `fn::query::register`). Empty
|
|
74
|
+
// strings when logged out — a non-null `auth` keeps `permission_inject` from
|
|
75
|
+
// rejecting `$auth`-gated tables; the predicate just degrades to its public
|
|
76
|
+
// branch. Set via `setSessionAuth` on every auth state change.
|
|
77
|
+
private sessionAuth: { authId: string; access: string } = { authId: '', access: '' };
|
|
71
78
|
|
|
72
79
|
constructor(
|
|
73
80
|
public events: EventSystem<StreamProcessorEvents>,
|
|
@@ -288,6 +295,28 @@ export class StreamProcessorService {
|
|
|
288
295
|
);
|
|
289
296
|
}
|
|
290
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Set the current session's auth identity for permission injection,
|
|
300
|
+
* mirroring the server's `fn::query::register`
|
|
301
|
+
* (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
|
|
302
|
+
* Stored as strings (empty when logged out) and applied to every
|
|
303
|
+
* `register_view` in {@link registerQueryPlan}. Must be set before a
|
|
304
|
+
* `$auth`-gated query registers (and re-set on auth state changes), or the
|
|
305
|
+
* in-browser SSP's `permission_inject` rejects it with
|
|
306
|
+
* "requires $auth but registration params lack it".
|
|
307
|
+
*/
|
|
308
|
+
setSessionAuth(authId: string | null, access: string | null) {
|
|
309
|
+
this.sessionAuth = { authId: authId ?? '', access: access ?? '' };
|
|
310
|
+
this.logger.debug(
|
|
311
|
+
{
|
|
312
|
+
authId: this.sessionAuth.authId,
|
|
313
|
+
access: this.sessionAuth.access,
|
|
314
|
+
Category: 'sp00ky-client::StreamProcessorService::setSessionAuth',
|
|
315
|
+
},
|
|
316
|
+
'Session auth context updated'
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
291
320
|
async saveState() {
|
|
292
321
|
if (!this.processor) return;
|
|
293
322
|
try {
|
|
@@ -411,10 +440,23 @@ export class StreamProcessorService {
|
|
|
411
440
|
try {
|
|
412
441
|
const normalizedParams = this.normalizeValue(queryPlan.params);
|
|
413
442
|
|
|
443
|
+
// Mirror the server's `fn::query::register` auth injection so the
|
|
444
|
+
// in-browser SSP can resolve `$auth`/`$access` in a table's permission
|
|
445
|
+
// predicate. Without this, any `$auth`-gated table (e.g. `thread`) is
|
|
446
|
+
// rejected by `permission_inject` and its local view never materializes.
|
|
447
|
+
// Injected only into the params handed to the in-browser SSP — never into
|
|
448
|
+
// the persisted `queryState.config.params` / query hash / server payload
|
|
449
|
+
// (the server does its own injection), so the view id stays shared.
|
|
450
|
+
const paramsWithAuth = {
|
|
451
|
+
...(normalizedParams as Record<string, unknown>),
|
|
452
|
+
auth: { id: this.sessionAuth.authId },
|
|
453
|
+
access: this.sessionAuth.access,
|
|
454
|
+
};
|
|
455
|
+
|
|
414
456
|
const initialUpdate = this.processor.register_view({
|
|
415
457
|
id: queryPlan.queryHash,
|
|
416
458
|
surql: queryPlan.surql,
|
|
417
|
-
params:
|
|
459
|
+
params: paramsWithAuth,
|
|
418
460
|
clientId: 'local',
|
|
419
461
|
ttl: queryPlan.ttl.toString(),
|
|
420
462
|
lastActiveAt: new Date().toISOString(),
|
package/src/sp00ky.ts
CHANGED
|
@@ -41,7 +41,7 @@ import { CrdtManager, CrdtField } from './modules/crdt/index';
|
|
|
41
41
|
import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/index';
|
|
42
42
|
import type { FeatureFlagOptions } from './modules/feature-flag/index';
|
|
43
43
|
import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
|
|
44
|
-
import { parseParams } from './utils/index';
|
|
44
|
+
import { parseParams, encodeRecordId } from './utils/index';
|
|
45
45
|
import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
|
|
46
46
|
import { ResilientPersistenceClient } from './services/persistence/resilient';
|
|
47
47
|
|
|
@@ -381,6 +381,16 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
381
381
|
// table.
|
|
382
382
|
this.auth.subscribe(async (userId) => {
|
|
383
383
|
this.dataModule.setCurrentUserId(userId);
|
|
384
|
+
// Mirror the server's `fn::query::register` auth injection for the
|
|
385
|
+
// in-browser SSP: feed the current user's full record id + access
|
|
386
|
+
// method so `$auth`-gated table permissions (e.g. `thread`) resolve
|
|
387
|
+
// locally instead of being rejected. Set synchronously BEFORE the
|
|
388
|
+
// first `await` (like `setCurrentUserId` above) so queries that
|
|
389
|
+
// re-register on this auth flip see the fresh context, not a stale one.
|
|
390
|
+
this.streamProcessor.setSessionAuth(
|
|
391
|
+
this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null,
|
|
392
|
+
this.auth.access
|
|
393
|
+
);
|
|
384
394
|
const next = await this.fetchSessionId();
|
|
385
395
|
this.dataModule.setSessionId(next);
|
|
386
396
|
this.crdtManager.setSessionId(next);
|
package/src/types.ts
CHANGED
|
@@ -178,6 +178,14 @@ export interface QueryConfig {
|
|
|
178
178
|
localArray: RecordVersionArray;
|
|
179
179
|
/** The version array representing the remote (server) state of results. */
|
|
180
180
|
remoteArray: RecordVersionArray;
|
|
181
|
+
/**
|
|
182
|
+
* In-memory only (never persisted to `_00_query`): version array of the
|
|
183
|
+
* subquery CHILD rows pulled via `parent IS NOT NONE` edges, so the
|
|
184
|
+
* child-body sync is idempotent across polls. Kept separate from
|
|
185
|
+
* `remoteArray` so related child rows never enter the primary window /
|
|
186
|
+
* `rowCount` / `localArray`.
|
|
187
|
+
*/
|
|
188
|
+
subqueryRemoteArray?: RecordVersionArray;
|
|
181
189
|
/** Time-To-Live for this query. */
|
|
182
190
|
ttl: QueryTimeToLive;
|
|
183
191
|
/** Timestamp when the query was last accessed/active. */
|