@spooky-sync/core 0.0.1-canary.34 → 0.0.1-canary.340
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/AGENTS.md +90 -0
- package/README.md +59 -16
- package/dist/index.d.ts +2115 -142
- package/dist/index.js +16427 -2575
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +389 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +477 -0
- package/dist/types.d.ts +1917 -38
- package/dist/worker-edges.js +480 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +37 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- package/src/boot/auth-flip.saga.test.ts +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -0
- package/src/boot/bucket-switch.saga.test.ts +189 -0
- package/src/boot/bucket-switch.saga.ts +136 -0
- package/src/boot/preload.saga.test.ts +80 -0
- package/src/boot/preload.saga.ts +40 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +235 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.test.ts +74 -0
- package/src/client/services.ts +435 -0
- package/src/client/sp00ky-client.test.ts +341 -0
- package/src/client/sp00ky-client.ts +625 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +167 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +512 -0
- package/src/live/index.ts +1059 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +228 -0
- package/src/modules/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +543 -22
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +515 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +793 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +748 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/ingest-outage.integration.test.ts +400 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +361 -0
- package/src/mutation/push.saga.ts +245 -0
- package/src/mutation/rows.test.ts +278 -0
- package/src/mutation/rows.ts +499 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +531 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +188 -0
- package/src/query/materialize.saga.ts +96 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +482 -0
- package/src/query/membership.saga.ts +315 -0
- package/src/query/membership.test.ts +151 -0
- package/src/query/membership.ts +234 -0
- package/src/query/register.saga.test.ts +312 -0
- package/src/query/register.saga.ts +235 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/session-guard.integration.test.ts +304 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +157 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +209 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +156 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +61 -16
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +508 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +285 -6
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +261 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +806 -67
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +652 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +216 -0
- package/src/state/lifecycle.test.ts +184 -0
- package/src/state/lifecycle.ts +127 -0
- package/src/state/reducers.test.ts +319 -0
- package/src/state/reducers.ts +550 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +259 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -0
- package/src/sync/live.saga.test.ts +229 -0
- package/src/sync/live.saga.ts +116 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +663 -0
- package/src/sync/policy.ts +515 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +661 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +57 -0
- package/src/utils/error-classification.test.ts +105 -0
- package/src/utils/error-classification.ts +64 -0
- package/src/utils/index.ts +89 -12
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +29 -18
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import type { QueryHash, QueryTimeToLive } from '../types';
|
|
4
|
+
import type { Saga } from '../kernel/saga';
|
|
5
|
+
import type { RegisterResult, StatementResult } from '../kernel/effects';
|
|
6
|
+
import { fx } from '../kernel/effects';
|
|
7
|
+
import { AUTH_READY_MAX_ATTEMPTS, AUTH_READY_RETRY_MS, REGISTER_MAX_RETRIES, backoffMs } from '../kernel/constants';
|
|
8
|
+
import type { ClientState, QueryEntry } from '../state/client-state';
|
|
9
|
+
import { emptyTelemetry } from '../state/client-state';
|
|
10
|
+
import { seedLifecycle } from '../state/lifecycle';
|
|
11
|
+
import * as R from '../state/reducers';
|
|
12
|
+
import { desiredRegistrations } from '../state/selectors';
|
|
13
|
+
import { parseDuration } from '../utils/index';
|
|
14
|
+
import type { SagaEnv } from './env';
|
|
15
|
+
import { listRefTable } from './env';
|
|
16
|
+
import { queryHashInput, viewKeyInput } from './hash';
|
|
17
|
+
import { FAILED, isResolvedBefore, parseViewRow, snapshotFromSingle } from './membership';
|
|
18
|
+
import { applyMembership, applySubqueryChildren, markMembershipDirty } from './membership.saga';
|
|
19
|
+
import * as sql from './sql';
|
|
20
|
+
|
|
21
|
+
export interface RegisterInput {
|
|
22
|
+
tableName: string;
|
|
23
|
+
surql: string;
|
|
24
|
+
params: Record<string, unknown>;
|
|
25
|
+
ttl: QueryTimeToLive;
|
|
26
|
+
plan?: QueryPlan;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Register a query locally: compute its keys, read the durable `_00_view`
|
|
31
|
+
* row, build the SSP local view and publish the entry. Returns as soon as the
|
|
32
|
+
* entry exists (the first paint comes from `materialize`); the remote
|
|
33
|
+
* registration is dispatched, never awaited.
|
|
34
|
+
*/
|
|
35
|
+
export function* registerLocal(env: SagaEnv, input: RegisterInput): Saga<QueryHash> {
|
|
36
|
+
const sessionId = (yield fx.state.read((s) => s.sessionId)) as string | null;
|
|
37
|
+
const hash = (yield fx.hash(queryHashInput(input, sessionId))) as string;
|
|
38
|
+
const status = (yield fx.state.read((s) => (s.queries.has(hash) ? 'active' : s.registering.has(hash) ? 'pending' : 'new'))) as
|
|
39
|
+
| 'active'
|
|
40
|
+
| 'pending'
|
|
41
|
+
| 'new';
|
|
42
|
+
if (status === 'active') return hash;
|
|
43
|
+
if (status === 'pending') {
|
|
44
|
+
yield fx.state.wait((s) => !s.registering.has(hash));
|
|
45
|
+
return hash;
|
|
46
|
+
}
|
|
47
|
+
yield fx.state.update(R.beginRegistering(hash));
|
|
48
|
+
try {
|
|
49
|
+
const viewKey = (yield fx.hash(viewKeyInput(input))) as string;
|
|
50
|
+
let viewRow: unknown = null;
|
|
51
|
+
try {
|
|
52
|
+
viewRow = yield fx.local.getById(sql.VIEW_TABLE, sql.viewRecordId(viewKey));
|
|
53
|
+
} catch {
|
|
54
|
+
viewRow = null;
|
|
55
|
+
}
|
|
56
|
+
const view = parseViewRow(viewRow);
|
|
57
|
+
const now = (yield fx.now()) as number;
|
|
58
|
+
const reg = (yield fx.ssp.register({
|
|
59
|
+
queryHash: hash,
|
|
60
|
+
surql: input.surql,
|
|
61
|
+
params: input.params,
|
|
62
|
+
ttl: input.ttl,
|
|
63
|
+
tableName: input.tableName,
|
|
64
|
+
})) as RegisterResult;
|
|
65
|
+
const entry: QueryEntry = {
|
|
66
|
+
def: {
|
|
67
|
+
id: new RecordId('_00_query', hash),
|
|
68
|
+
hash,
|
|
69
|
+
viewKey,
|
|
70
|
+
surql: input.surql,
|
|
71
|
+
params: input.params,
|
|
72
|
+
plan: input.plan,
|
|
73
|
+
ttl: input.ttl,
|
|
74
|
+
ttlMs: parseDuration(input.ttl),
|
|
75
|
+
tableName: input.tableName,
|
|
76
|
+
createdAt: now,
|
|
77
|
+
},
|
|
78
|
+
lifecycle: seedLifecycle(isResolvedBefore(view)),
|
|
79
|
+
remoteArray: view?.ids ?? [],
|
|
80
|
+
localArray: reg.localArray,
|
|
81
|
+
subqueryRemoteArray: [],
|
|
82
|
+
records: [],
|
|
83
|
+
serverState: null,
|
|
84
|
+
subscribers: 0,
|
|
85
|
+
lastSubscriberLeftAt: now,
|
|
86
|
+
lastHeartbeatAt: null,
|
|
87
|
+
lastPolledAt: null,
|
|
88
|
+
registerAttempts: 0,
|
|
89
|
+
viewLostCount: 0,
|
|
90
|
+
viewLostRetryAt: null,
|
|
91
|
+
telemetry: { ...emptyTelemetry(), registrationTimings: reg.timings },
|
|
92
|
+
};
|
|
93
|
+
yield fx.state.update(R.putQuery(entry));
|
|
94
|
+
yield fx.dispatch({ type: 'EnsureRegistered' });
|
|
95
|
+
return hash;
|
|
96
|
+
} finally {
|
|
97
|
+
yield fx.state.update(R.endRegistering(hash));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Make the remote match the desired set: start a registration for every
|
|
103
|
+
* query that has none. Idempotent. With `requireAuth` (after a reconnect or
|
|
104
|
+
* while degraded) it first waits for `$auth.id` to be visible on the socket:
|
|
105
|
+
* a register issued before that stamps the view with an empty identity.
|
|
106
|
+
*/
|
|
107
|
+
export function* ensureRegistered(env: SagaEnv, opts: { requireAuth?: boolean; attempt?: number } = {}): Saga<void> {
|
|
108
|
+
const [hashes, userId] = (yield fx.state.read((s) => [desiredRegistrations(s), s.userId])) as [QueryHash[], string | null];
|
|
109
|
+
if (hashes.length === 0) return;
|
|
110
|
+
if (opts.requireAuth && userId !== null) {
|
|
111
|
+
let authed = false;
|
|
112
|
+
try {
|
|
113
|
+
const res = (yield fx.remote.query('RETURN $auth.id', undefined, env.remoteTimeoutMs)) as StatementResult[];
|
|
114
|
+
authed = res[0]?.status === 'OK' && res[0].result != null;
|
|
115
|
+
} catch {
|
|
116
|
+
authed = false;
|
|
117
|
+
}
|
|
118
|
+
if (!authed) {
|
|
119
|
+
const attempt = (opts.attempt ?? 0) + 1;
|
|
120
|
+
if (attempt >= AUTH_READY_MAX_ATTEMPTS) {
|
|
121
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'auth identity never became visible; not registering', data: { attempt } });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
yield fx.timer.set('ensure-registered', AUTH_READY_RETRY_MS, { type: 'EnsureRegistered', requireAuth: true, attempt });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const hash of hashes) yield fx.dispatch({ type: 'RegisterRemote', hash });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const stmt = (results: StatementResult[], i: number): StatementResult | undefined => results[i];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The SSP answers a registration whose shape is not on the query allowlist
|
|
135
|
+
* with HTTP 403 `{"error":"not_allowlisted"}`. It reaches the client as the
|
|
136
|
+
* `fn::query::register` statement error carrying SurrealDB's http error text
|
|
137
|
+
* (status reason and/or body), and no retry can change the answer.
|
|
138
|
+
*/
|
|
139
|
+
export const isNotAllowlisted = (error: unknown): boolean =>
|
|
140
|
+
/not_allowlisted|\b403\b|forbidden/i.test(String((error as { message?: unknown })?.message ?? error));
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One remote registration: `fn::query::register` plus the edge/meta/children
|
|
144
|
+
* read in ONE request, then membership application. Concurrent with every
|
|
145
|
+
* other registration; retried on its own backoff; stops when the entry is
|
|
146
|
+
* gone.
|
|
147
|
+
*/
|
|
148
|
+
export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): Saga<void> {
|
|
149
|
+
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
150
|
+
const entry = state.queries.get(hash);
|
|
151
|
+
if (!entry) return;
|
|
152
|
+
const { remote } = entry.lifecycle;
|
|
153
|
+
if (remote === 'registered' || remote === 'failed' || (remote === 'registering' && !retry)) return;
|
|
154
|
+
// A query that already shows rows (cached or live) only re-verifies them
|
|
155
|
+
// here: the cycle holds `fetching` but is not traffic the user waits on.
|
|
156
|
+
const background = entry.lifecycle.phase !== 'cold';
|
|
157
|
+
yield fx.state.update(
|
|
158
|
+
R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin', background }))
|
|
159
|
+
);
|
|
160
|
+
try {
|
|
161
|
+
const table = listRefTable(env, state);
|
|
162
|
+
const results = (yield fx.remote.query(
|
|
163
|
+
sql.registerSelect(table),
|
|
164
|
+
sql.registerVars({
|
|
165
|
+
id: entry.def.id,
|
|
166
|
+
surql: entry.def.surql,
|
|
167
|
+
params: { ...entry.def.params },
|
|
168
|
+
ttl: entry.def.ttl,
|
|
169
|
+
...(state.ownerSecret ? { owner: state.ownerSecret } : {}),
|
|
170
|
+
}),
|
|
171
|
+
env.remoteTimeoutMs
|
|
172
|
+
)) as StatementResult[];
|
|
173
|
+
const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
|
174
|
+
if (!stillThere) return;
|
|
175
|
+
const register = stmt(results, 0);
|
|
176
|
+
if (!register || register.status === 'ERR') throw new Error(register?.status === 'ERR' ? register.error : 'register returned nothing');
|
|
177
|
+
const answer = (r: StatementResult | undefined): unknown => (r?.status === 'OK' ? r.result : FAILED);
|
|
178
|
+
const snap = snapshotFromSingle(
|
|
179
|
+
answer(stmt(results, 1)) as never,
|
|
180
|
+
answer(stmt(results, 2)) as never,
|
|
181
|
+
answer(stmt(results, 3)) as never
|
|
182
|
+
);
|
|
183
|
+
if (!snap) {
|
|
184
|
+
// Registered, but the read-back did not answer. That says nothing about
|
|
185
|
+
// the row, so read membership again rather than guess: a guess of
|
|
186
|
+
// "gone" re-registered the query, and under load the next read-back
|
|
187
|
+
// failed the same way.
|
|
188
|
+
yield fx.state.update(R.compose(R.applyLifecycle(hash, { type: 'remote-registered' }), R.resetRegisterAttempts(hash)));
|
|
189
|
+
yield fx.emit({ type: 'log', level: 'debug', message: 'registration read-back did not answer; re-reading membership', data: { hash } });
|
|
190
|
+
yield* markMembershipDirty([hash]);
|
|
191
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: false, error: 'registration read-back failed' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const outcome = yield* applyMembership(hash, snap.primary, snap.meta);
|
|
195
|
+
if (outcome === 'ignored' && snap.meta.present && snap.meta.state === 'materializing') {
|
|
196
|
+
yield fx.state.update(R.compose(R.markMembershipDirty([hash]), R.setMembershipReread(hash, 1)));
|
|
197
|
+
yield fx.timer.set('membership', 150, { type: 'ReadDirtyMembership' });
|
|
198
|
+
}
|
|
199
|
+
yield* applySubqueryChildren(hash, snap.subquery);
|
|
200
|
+
yield fx.state.update(R.compose(R.applyLifecycle(hash, { type: 'remote-registered' }), R.resetRegisterAttempts(hash)));
|
|
201
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: true });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
|
|
204
|
+
if (!current) return;
|
|
205
|
+
if (isNotAllowlisted(error)) {
|
|
206
|
+
yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
|
|
207
|
+
yield fx.emit({
|
|
208
|
+
type: 'log',
|
|
209
|
+
level: 'error',
|
|
210
|
+
message: 'query refused: not allowlisted',
|
|
211
|
+
data: {
|
|
212
|
+
hash,
|
|
213
|
+
table: entry.def.tableName,
|
|
214
|
+
surql: entry.def.surql,
|
|
215
|
+
params: Object.keys(entry.def.params),
|
|
216
|
+
hint: 'Add or regenerate the query allowlist: spky generate, then spky deploy (or restart spky dev).',
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const attempts = current.registerAttempts + 1;
|
|
223
|
+
yield fx.state.update(R.bumpRegisterAttempts(hash));
|
|
224
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'remote registration failed', data: { hash, attempts, error } });
|
|
225
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
|
|
226
|
+
if (attempts >= REGISTER_MAX_RETRIES) {
|
|
227
|
+
yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
|
|
228
|
+
} else {
|
|
229
|
+
yield fx.timer.set(`register:${hash}`, backoffMs(attempts), { type: 'RegisterRemote', hash, retry: true });
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
|
|
233
|
+
if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildRenderIds, paintsCold, resolveMembership } from './render-set';
|
|
3
|
+
|
|
4
|
+
const ov = (writes: string[] = [], deletes: string[] = []) => ({ writes: new Set(writes), deletes: new Set(deletes) });
|
|
5
|
+
|
|
6
|
+
describe('resolveMembership', () => {
|
|
7
|
+
it('cold scans, cold window uses the local view, anything else uses membership', () => {
|
|
8
|
+
const remoteArray: [string, number][] = [['t:1', 1]];
|
|
9
|
+
const localArray: [string, number][] = [['t:2', 1]];
|
|
10
|
+
expect(resolveMembership({ phase: 'cold', remoteArray, localArray, isWindow: false })).toBeNull();
|
|
11
|
+
expect(resolveMembership({ phase: 'cold', remoteArray, localArray, isWindow: true })).toBe(localArray);
|
|
12
|
+
expect(resolveMembership({ phase: 'cached', remoteArray, localArray, isWindow: false })).toBe(remoteArray);
|
|
13
|
+
expect(resolveMembership({ phase: 'view-lost', remoteArray, localArray, isWindow: true })).toBe(remoteArray);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('buildRenderIds', () => {
|
|
18
|
+
const opts = { hasExplicitOrder: false, isWindow: false };
|
|
19
|
+
it('membership minus deletes, sorted', () => {
|
|
20
|
+
expect(buildRenderIds([['t:b', 1], ['t:a', 1], ['t:c', 1]], [], ov([], ['t:c']), opts)).toEqual(['t:a', 't:b']);
|
|
21
|
+
});
|
|
22
|
+
it('adds pending writes the local view admits, never twice, never deleted', () => {
|
|
23
|
+
const ids = buildRenderIds(
|
|
24
|
+
[['t:1', 1]],
|
|
25
|
+
[['t:1', 1], ['t:2', 1], ['t:3', 1], ['t:4', 1]],
|
|
26
|
+
ov(['t:1', 't:2', 't:3', 't:9'], ['t:3']),
|
|
27
|
+
opts
|
|
28
|
+
);
|
|
29
|
+
expect(ids).toEqual(['t:1', 't:2']);
|
|
30
|
+
});
|
|
31
|
+
it('keeps server order for windows and explicit orderBy; dedupes membership', () => {
|
|
32
|
+
const m: [string, number][] = [['t:b', 1], ['t:a', 1], ['t:b', 1]];
|
|
33
|
+
expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: true, isWindow: false })).toEqual(['t:b', 't:a']);
|
|
34
|
+
expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: false, isWindow: true })).toEqual(['t:b', 't:a']);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('paintsCold', () => {
|
|
39
|
+
it('only vouched bodies, optimistic writes and bookkeeping paint before the server answers', () => {
|
|
40
|
+
const vouched = new Set(['t:1']);
|
|
41
|
+
expect(paintsCold('t:1', vouched, ov())).toBe(true);
|
|
42
|
+
expect(paintsCold('t:2', vouched, ov(['t:2']))).toBe(true);
|
|
43
|
+
expect(paintsCold('_00_user_feature:f', vouched, ov())).toBe(true);
|
|
44
|
+
expect(paintsCold('t:3', vouched, ov([], ['t:3']))).toBe(false);
|
|
45
|
+
expect(paintsCold('t:3', vouched, ov())).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { RecordVersionArray } from '../types';
|
|
2
|
+
import type { QueryPhase } from '../state/lifecycle';
|
|
3
|
+
import type { Overlay } from '../state/selectors';
|
|
4
|
+
|
|
5
|
+
export interface RenderInput {
|
|
6
|
+
phase: QueryPhase;
|
|
7
|
+
remoteArray: RecordVersionArray;
|
|
8
|
+
localArray: RecordVersionArray;
|
|
9
|
+
isWindow: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The id-set a query renders from, or `null` when it must fall back to a
|
|
14
|
+
* local predicate scan (a cold query that was never resolved on this device).
|
|
15
|
+
* A cold window has no usable scan (re-applying `START m` against the shared
|
|
16
|
+
* store returns the wrong rows), so it renders the SSP's local window instead.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveMembership(input: RenderInput): RecordVersionArray | null {
|
|
19
|
+
if (input.phase !== 'cold') return input.remoteArray;
|
|
20
|
+
if (input.isWindow) return input.localArray;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a body may paint before the server has answered (`cold`). One no
|
|
26
|
+
* durable view vouches for and no optimistic write touches is an orphan:
|
|
27
|
+
* deleted upstream while no view watched it, or dropped from every view since.
|
|
28
|
+
* The predicate scan would otherwise resurrect it until the server answers,
|
|
29
|
+
* and indefinitely offline.
|
|
30
|
+
*/
|
|
31
|
+
export function paintsCold(id: string, vouched: ReadonlySet<string>, overlay: Overlay): boolean {
|
|
32
|
+
return id.startsWith('_00_') || vouched.has(id) || overlay.writes.has(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RenderOptions {
|
|
36
|
+
hasExplicitOrder: boolean;
|
|
37
|
+
isWindow: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* render = (membership ∪ (writes ∩ localView)) − deletes
|
|
42
|
+
*
|
|
43
|
+
* `writes`/`deletes` are the outbox overlay (pending and acked items). The
|
|
44
|
+
* middle term keeps optimistic writes visible without re-admitting stale rows:
|
|
45
|
+
* `localView` (the SSP's local id-set) says whether the written row matches
|
|
46
|
+
* the query per local truth. Sorted unless the query orders itself or is a
|
|
47
|
+
* window (whose id order is the slice order).
|
|
48
|
+
*/
|
|
49
|
+
export function buildRenderIds(
|
|
50
|
+
membership: RecordVersionArray,
|
|
51
|
+
localView: RecordVersionArray,
|
|
52
|
+
overlay: Overlay,
|
|
53
|
+
opts: RenderOptions
|
|
54
|
+
): string[] {
|
|
55
|
+
const ordered: string[] = [];
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
for (const [id] of membership) {
|
|
58
|
+
if (overlay.deletes.has(id) || seen.has(id)) continue;
|
|
59
|
+
seen.add(id);
|
|
60
|
+
ordered.push(id);
|
|
61
|
+
}
|
|
62
|
+
if (overlay.writes.size > 0) {
|
|
63
|
+
for (const [id] of localView) {
|
|
64
|
+
if (!overlay.writes.has(id) || overlay.deletes.has(id) || seen.has(id)) continue;
|
|
65
|
+
seen.add(id);
|
|
66
|
+
ordered.push(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!opts.hasExplicitOrder && !opts.isWindow) ordered.sort();
|
|
70
|
+
return ordered;
|
|
71
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { Socket } from 'node:net';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { RecordId, Surreal } from 'surrealdb';
|
|
7
|
+
import { liveEndpoint } from '../live/index';
|
|
8
|
+
import { isRefusedSessionError } from '../utils/error-classification';
|
|
9
|
+
import { heartbeatBatch, heartbeatRowGone, queryRowCountSelect } from './sql';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The app's session guard (`sessionGuard` in sp00ky.yml, the SESSION GUARD
|
|
13
|
+
* block of `meta_tables_remote.surql`) ends sync for a WebSocket that is still
|
|
14
|
+
* open. SurrealDB checks a token only when a socket authenticates, so before
|
|
15
|
+
* the guard a socket that authenticated before a password reset kept reading
|
|
16
|
+
* its user's edges, heartbeating its views and registering new ones until it
|
|
17
|
+
* happened to reconnect.
|
|
18
|
+
*
|
|
19
|
+
* Runs the shipped guard, `_00_query` and `_00_list_ref` blocks, the
|
|
20
|
+
* `fn::query::*` functions and the per-user edge-table rule ssp-node defines,
|
|
21
|
+
* against a real SurrealDB, because the guarantee is the engine's evaluation
|
|
22
|
+
* of those rules, LIVE notifications included. Boot one first, e.g.:
|
|
23
|
+
* docker run -d --name spooky-surreal-it -p 18011:8000 \
|
|
24
|
+
* surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
|
|
25
|
+
*
|
|
26
|
+
* Override the endpoint with SURREAL_IT_ENDPOINT. Skips if unreachable.
|
|
27
|
+
*/
|
|
28
|
+
const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
|
|
29
|
+
const NS = 'it';
|
|
30
|
+
|
|
31
|
+
/** The guard WhitePawn ships: a password reset stamps `session_epoch`. */
|
|
32
|
+
const GUARD = '$auth.session_epoch = NONE OR ($token.iat ?? 0) >= $auth.session_epoch';
|
|
33
|
+
|
|
34
|
+
const repoFile = (path: string) =>
|
|
35
|
+
readFileSync(fileURLToPath(new URL(`../../../../${path}`, import.meta.url)), 'utf8');
|
|
36
|
+
const cliSource = (file: string) => repoFile(`apps/cli/src/${file}`);
|
|
37
|
+
|
|
38
|
+
function slice(src: string, from: string, to: string): string {
|
|
39
|
+
const start = src.indexOf(from);
|
|
40
|
+
const end = src.indexOf(to, start);
|
|
41
|
+
if (start < 0 || end < start) throw new Error(`block ${from} .. ${to} not found`);
|
|
42
|
+
return src.slice(start, end);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The SESSION GUARD block, with `expr` put in the way `spky migrate` does
|
|
47
|
+
* (`schema_builder::apply_session_guard`, whose marker is read from the Rust
|
|
48
|
+
* source so the two cannot drift).
|
|
49
|
+
*/
|
|
50
|
+
function guardSchema(expr?: string): string {
|
|
51
|
+
const block = slice(
|
|
52
|
+
cliSource('meta_tables_remote.surql'),
|
|
53
|
+
'-- SPOOKY SESSION GUARD',
|
|
54
|
+
'-- SPOOKY INCANTATION'
|
|
55
|
+
);
|
|
56
|
+
const m = /pub const SESSION_GUARD_DEFAULT: &str = "([^"]+)";/.exec(
|
|
57
|
+
repoFile('apps/cli/src/schema_builder.rs')
|
|
58
|
+
);
|
|
59
|
+
if (!m || !block.includes(m[1])) throw new Error('session guard marker not found');
|
|
60
|
+
return expr ? block.replace(m[1], `RETURN (${expr}) == true;`) : block;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Just the tables this test needs: the rest of the file needs modules. */
|
|
64
|
+
const queryTableSchema = () =>
|
|
65
|
+
slice(
|
|
66
|
+
cliSource('meta_tables_remote.surql'),
|
|
67
|
+
'DEFINE TABLE OVERWRITE _00_query SCHEMALESS',
|
|
68
|
+
'-- SPOOKY LIST REF'
|
|
69
|
+
);
|
|
70
|
+
const listRefSchema = () =>
|
|
71
|
+
slice(
|
|
72
|
+
cliSource('meta_tables_remote.surql'),
|
|
73
|
+
'DEFINE TABLE OVERWRITE _00_list_ref SCHEMALESS',
|
|
74
|
+
'-- SPOOKY STREAM PROCESSOR STATE'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/** The rule ssp-node puts on `_00_list_ref_user_<uid>` (`user_list_ref_ddl` in tables.rs). */
|
|
78
|
+
function userListRefTable(uid: string): string {
|
|
79
|
+
const m = /PERMISSIONS FOR select WHERE (\$auth\.id = user:\{uid\}[^,]*),/.exec(
|
|
80
|
+
repoFile('packages/ssp-node/src/tables.rs')
|
|
81
|
+
);
|
|
82
|
+
if (!m) throw new Error('per-user list_ref rule not found in ssp-node/src/tables.rs');
|
|
83
|
+
return `DEFINE TABLE OVERWRITE _00_list_ref_user_${uid} SCHEMALESS
|
|
84
|
+
PERMISSIONS FOR select WHERE ${m[1].replace('{uid}', uid)}, FOR create, update, delete WHERE false;`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** An app schema: a hidden `session_epoch` and an app table that asks the guard too. */
|
|
88
|
+
const APP = `
|
|
89
|
+
DEFINE TABLE OVERWRITE user SCHEMAFULL PERMISSIONS FOR select WHERE id = $auth.id;
|
|
90
|
+
DEFINE FIELD OVERWRITE name ON TABLE user TYPE string;
|
|
91
|
+
DEFINE FIELD OVERWRITE session_epoch ON TABLE user TYPE option<int>
|
|
92
|
+
PERMISSIONS FOR select WHERE false, FOR create, update WHERE false;
|
|
93
|
+
DEFINE ACCESS OVERWRITE account ON DATABASE TYPE RECORD
|
|
94
|
+
SIGNUP (CREATE ONLY type::record('user', $name) SET name = $name)
|
|
95
|
+
SIGNIN (SELECT * FROM user WHERE name = $name)
|
|
96
|
+
DURATION FOR TOKEN 1h, FOR SESSION 1h;
|
|
97
|
+
DEFINE TABLE OVERWRITE game SCHEMALESS
|
|
98
|
+
PERMISSIONS FOR select WHERE owner = $auth.id AND fn::sp00ky::session_live();
|
|
99
|
+
CREATE game:1 SET owner = user:alice;
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
/** What the SSP writes, as root, when a session registers a view (`node.rs`, cold path). */
|
|
103
|
+
const SSP_REGISTER = `
|
|
104
|
+
UPSERT $id SET clientId = $sid, auth_id = 'user:alice', surql = 'SELECT * FROM game',
|
|
105
|
+
params = { auth: { id: 'user:alice' }, access: 'account' }, ttl = 1h, lastActiveAt = time::now(),
|
|
106
|
+
rowCount = 1, state = 'ready',
|
|
107
|
+
subscribers = array::append(
|
|
108
|
+
array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
|
|
109
|
+
{ id: $sid, seenAt: time::now() })`;
|
|
110
|
+
|
|
111
|
+
const viewId = () =>
|
|
112
|
+
new RecordId('_00_query', createHash('sha256').update(randomUUID()).digest('hex'));
|
|
113
|
+
|
|
114
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
115
|
+
|
|
116
|
+
describe('session guard ends sync for an open socket (real SurrealDB)', () => {
|
|
117
|
+
let root: Surreal;
|
|
118
|
+
let database = '';
|
|
119
|
+
let reachable = false;
|
|
120
|
+
const open: Surreal[] = [];
|
|
121
|
+
|
|
122
|
+
beforeAll(async () => {
|
|
123
|
+
const url = new URL(endpoint);
|
|
124
|
+
reachable = await new Promise<boolean>((resolve) => {
|
|
125
|
+
const socket = new Socket();
|
|
126
|
+
const done = (ok: boolean) => {
|
|
127
|
+
socket.destroy();
|
|
128
|
+
resolve(ok);
|
|
129
|
+
};
|
|
130
|
+
socket.setTimeout(1_000, () => done(false));
|
|
131
|
+
socket.once('error', () => done(false));
|
|
132
|
+
socket.connect(Number(url.port || 80), url.hostname, () => done(true));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/** Root, a fresh database, the app and sp00ky's rules with `guard` in place. */
|
|
137
|
+
async function boot(guard?: string): Promise<void> {
|
|
138
|
+
database = `session_guard_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
139
|
+
root = new Surreal();
|
|
140
|
+
await root.connect(endpoint, { reconnect: false });
|
|
141
|
+
await root.signin({ username: 'root', password: 'root' });
|
|
142
|
+
await root.use({ namespace: NS, database });
|
|
143
|
+
await root.query(guardSchema(guard));
|
|
144
|
+
await root.query(APP);
|
|
145
|
+
await root.query(queryTableSchema());
|
|
146
|
+
await root.query(listRefSchema());
|
|
147
|
+
await root.query(userListRefTable('alice'));
|
|
148
|
+
await root.query(cliSource('functions_remote.surql'));
|
|
149
|
+
// The guard throws before the post; a live session's post fails on this
|
|
150
|
+
// closed port, which is not a refused session.
|
|
151
|
+
await root.query(
|
|
152
|
+
cliSource('functions_remote_singlenode.surql')
|
|
153
|
+
.replaceAll('{{ENDPOINT}}', 'http://127.0.0.1:9')
|
|
154
|
+
.replaceAll('{{SECRET}}', 'it-secret')
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
afterEach(async () => {
|
|
159
|
+
if (!reachable || !root) return;
|
|
160
|
+
for (const db of open.splice(0)) await db.close().catch(() => undefined);
|
|
161
|
+
await root.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
|
|
162
|
+
await root.close();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/** Alice on a socket of her own, and that socket's `session::id()`. */
|
|
166
|
+
async function alice(how: 'signup' | 'signin'): Promise<[Surreal, string]> {
|
|
167
|
+
const db = new Surreal();
|
|
168
|
+
open.push(db);
|
|
169
|
+
await db.connect(endpoint, { reconnect: false });
|
|
170
|
+
await db.use({ namespace: NS, database });
|
|
171
|
+
await db[how]({ namespace: NS, database, access: 'account', variables: { name: 'alice' } });
|
|
172
|
+
const [sid] = (await db.query('RETURN <string>session::id()')) as [string];
|
|
173
|
+
return [db, sid];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** What a password reset does: every token issued before now stops counting. */
|
|
177
|
+
async function resetPassword(stale: Surreal): Promise<void> {
|
|
178
|
+
const [iat] = (await stale.query('RETURN $token.iat')) as [number];
|
|
179
|
+
const epoch = iat + 1;
|
|
180
|
+
await root.query('UPDATE user:alice SET session_epoch = $epoch', { epoch });
|
|
181
|
+
// `iat` has second precision: the next sign-in must land at or past the epoch.
|
|
182
|
+
while (Date.now() / 1000 < epoch) await sleep(50);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** LIVE on the per-user edge table; returns the notifications seen so far. */
|
|
186
|
+
async function watchEdges(db: Surreal): Promise<unknown[]> {
|
|
187
|
+
const seen: unknown[] = [];
|
|
188
|
+
const [id] = (await db.query('LIVE SELECT * FROM _00_list_ref_user_alice')) as [unknown];
|
|
189
|
+
const stream = await db.liveOf(id as never);
|
|
190
|
+
// Typed by the SDK (`LiveMessage`), not a narrower hand-written shape:
|
|
191
|
+
// the hand-written one failed `tsc` and so the core Test job.
|
|
192
|
+
stream.subscribe((message) => {
|
|
193
|
+
if (message.action !== 'KILLED') seen.push(message.value);
|
|
194
|
+
});
|
|
195
|
+
return seen;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const edge = (n: number) =>
|
|
199
|
+
root.query(
|
|
200
|
+
`CREATE type::record('_00_list_ref_user_alice', $n) SET in = _00_query:q, out = game:1, version = $n`,
|
|
201
|
+
{ n }
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
/** The client's register read-back, as `registerSelect` sends it. */
|
|
205
|
+
const readBack = async (db: Surreal, id: RecordId<string>) =>
|
|
206
|
+
((await db.query(queryRowCountSelect(), { in: id })) as [unknown])[0] ?? null;
|
|
207
|
+
|
|
208
|
+
const heartbeatFound = async (db: Surreal, id: RecordId<string>) => {
|
|
209
|
+
const beat = heartbeatBatch([id]);
|
|
210
|
+
const [result] = (await db.query(beat.sql, beat.vars)) as [unknown];
|
|
211
|
+
return !heartbeatRowGone(result);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const registerError = async (db: Surreal): Promise<unknown> =>
|
|
215
|
+
db
|
|
216
|
+
.query('RETURN fn::query::register($config)', {
|
|
217
|
+
config: { id: viewId(), surql: 'SELECT * FROM game', params: {}, ttl: '1h' },
|
|
218
|
+
})
|
|
219
|
+
.then(
|
|
220
|
+
() => null,
|
|
221
|
+
(error: unknown) => error
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
it('a socket from before the reset stops receiving edges, a fresh sign-in keeps them', async (ctx) => {
|
|
225
|
+
if (!reachable) ctx.skip();
|
|
226
|
+
await boot(GUARD);
|
|
227
|
+
const [stale] = await alice('signup');
|
|
228
|
+
const staleSeen = await watchEdges(stale);
|
|
229
|
+
await edge(1);
|
|
230
|
+
await expect.poll(() => staleSeen.length).toBe(1);
|
|
231
|
+
|
|
232
|
+
await resetPassword(stale);
|
|
233
|
+
const [fresh] = await alice('signin');
|
|
234
|
+
const freshSeen = await watchEdges(fresh);
|
|
235
|
+
await edge(2);
|
|
236
|
+
await edge(3);
|
|
237
|
+
|
|
238
|
+
await expect.poll(() => freshSeen.length).toBe(2);
|
|
239
|
+
await sleep(200);
|
|
240
|
+
expect(staleSeen.length).toBe(1);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('a socket from before the reset reads none of its user\'s edges, views or rows', async (ctx) => {
|
|
244
|
+
if (!reachable) ctx.skip();
|
|
245
|
+
await boot(GUARD);
|
|
246
|
+
const [stale, staleSid] = await alice('signup');
|
|
247
|
+
await edge(1);
|
|
248
|
+
await root.query(
|
|
249
|
+
`CREATE _00_list_ref:1 SET in = _00_query:q, out = game:1, clientId = 'x', auth_id = 'user:alice', version = 1`
|
|
250
|
+
);
|
|
251
|
+
await resetPassword(stale);
|
|
252
|
+
const [fresh, freshSid] = await alice('signin');
|
|
253
|
+
const q = viewId();
|
|
254
|
+
await root.query(SSP_REGISTER, { id: q, sid: staleSid });
|
|
255
|
+
await root.query(SSP_REGISTER, { id: q, sid: freshSid });
|
|
256
|
+
|
|
257
|
+
for (const table of ['_00_list_ref_user_alice', '_00_list_ref', 'game']) {
|
|
258
|
+
expect(await stale.query(`SELECT VALUE id FROM ${table}`), table).toEqual([[]]);
|
|
259
|
+
expect(((await fresh.query(`SELECT VALUE id FROM ${table}`)) as [unknown[]])[0], table).toHaveLength(1);
|
|
260
|
+
}
|
|
261
|
+
expect(await readBack(stale, q)).toBeNull();
|
|
262
|
+
expect(await heartbeatFound(stale, q)).toBe(false);
|
|
263
|
+
expect(await readBack(fresh, q)).toEqual({ rowCount: 1, state: 'ready' });
|
|
264
|
+
expect(await heartbeatFound(fresh, q)).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('fn::query::register refuses the stale socket as a refused session, and only that one', async (ctx) => {
|
|
268
|
+
if (!reachable) ctx.skip();
|
|
269
|
+
await boot(GUARD);
|
|
270
|
+
const [stale] = await alice('signup');
|
|
271
|
+
expect(isRefusedSessionError(await registerError(stale))).toBe(false);
|
|
272
|
+
|
|
273
|
+
await resetPassword(stale);
|
|
274
|
+
const [fresh] = await alice('signin');
|
|
275
|
+
|
|
276
|
+
const refused = await registerError(stale);
|
|
277
|
+
expect(isRefusedSessionError(refused), String(refused)).toBe(true);
|
|
278
|
+
expect(isRefusedSessionError(await registerError(fresh))).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('without a sessionGuard every session stays live, as before the guard', async (ctx) => {
|
|
282
|
+
if (!reachable) ctx.skip();
|
|
283
|
+
await boot();
|
|
284
|
+
const [stale, staleSid] = await alice('signup');
|
|
285
|
+
await edge(1);
|
|
286
|
+
const q = viewId();
|
|
287
|
+
await root.query(SSP_REGISTER, { id: q, sid: staleSid });
|
|
288
|
+
await resetPassword(stale);
|
|
289
|
+
|
|
290
|
+
expect(((await stale.query('SELECT VALUE id FROM _00_list_ref_user_alice')) as [unknown[]])[0]).toHaveLength(1);
|
|
291
|
+
expect(await heartbeatFound(stale, q)).toBe(true);
|
|
292
|
+
expect(isRefusedSessionError(await registerError(stale))).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('a database without the probe row lets every registration through', async (ctx) => {
|
|
296
|
+
if (!reachable) ctx.skip();
|
|
297
|
+
await boot(GUARD);
|
|
298
|
+
const [stale] = await alice('signup');
|
|
299
|
+
await resetPassword(stale);
|
|
300
|
+
await root.query('DELETE _00_session_guard:probe');
|
|
301
|
+
|
|
302
|
+
expect(isRefusedSessionError(await registerError(stale))).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
});
|