@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210
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 +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -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 +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- 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 +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- 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/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -67
- 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 +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -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 +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -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 +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- 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 +198 -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 +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -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 +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { encode, decode, isBlurhashValid } from 'blurhash';
|
|
2
|
+
|
|
3
|
+
export { encode as encodeBlurhash, decode as decodeBlurhash, isBlurhashValid };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Blurhash generation settings. `true` enables with the defaults below, `false`
|
|
7
|
+
* disables. Resolution order for a put: per-call option > client config >
|
|
8
|
+
* default ON. See {@link Sp00kyConfig.blurhash}.
|
|
9
|
+
*/
|
|
10
|
+
export type BlurhashSetting = boolean | BlurhashEncodeOptions;
|
|
11
|
+
|
|
12
|
+
export interface BlurhashEncodeOptions {
|
|
13
|
+
/** Horizontal detail components, 1-9. Defaults to 4. */
|
|
14
|
+
componentX?: number;
|
|
15
|
+
/** Vertical detail components, 1-9. Defaults to 3. */
|
|
16
|
+
componentY?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where an image's blurhash lives: a tiny sidecar object in the same bucket.
|
|
21
|
+
* Buckets have no per-object metadata channel (`put` is just `.put($content)`),
|
|
22
|
+
* so the hash for `covers/x_t.webp` is the text object `covers/x_t.webp.bh`.
|
|
23
|
+
*/
|
|
24
|
+
export function blurhashSidecarPath(path: string): string {
|
|
25
|
+
return `${path}.bh`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Extensions `bucket.put` treats as images worth hashing. */
|
|
29
|
+
export const BLURHASH_IMAGE_EXTENSIONS = [
|
|
30
|
+
'webp',
|
|
31
|
+
'png',
|
|
32
|
+
'jpg',
|
|
33
|
+
'jpeg',
|
|
34
|
+
'gif',
|
|
35
|
+
'avif',
|
|
36
|
+
'bmp',
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
const IMAGE_EXT_RE = new RegExp(`\\.(${BLURHASH_IMAGE_EXTENSIONS.join('|')})$`, 'i');
|
|
40
|
+
|
|
41
|
+
export function isImagePath(path: string): boolean {
|
|
42
|
+
return IMAGE_EXT_RE.test(path);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Longest edge of the downscale the hash is computed from. Blurhash carries at
|
|
46
|
+
* most 9x9 DCT components, so anything past ~32px is wasted decode work. */
|
|
47
|
+
const ENCODE_MAX_EDGE = 32;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decode `content` as an image and compute its blurhash. Browser-only: returns
|
|
51
|
+
* null (never throws) when image decoding is unavailable (node, workers without
|
|
52
|
+
* canvas), when the bytes are not a decodable image, or on any other failure —
|
|
53
|
+
* a missing hash must never break the upload that triggered it.
|
|
54
|
+
*/
|
|
55
|
+
export async function encodeImageToBlurhash(
|
|
56
|
+
content: string | Uint8Array | Blob,
|
|
57
|
+
options?: BlurhashEncodeOptions
|
|
58
|
+
): Promise<string | null> {
|
|
59
|
+
if (typeof createImageBitmap !== 'function') return null;
|
|
60
|
+
let bitmap: ImageBitmap | null = null;
|
|
61
|
+
try {
|
|
62
|
+
const blob =
|
|
63
|
+
content instanceof Blob
|
|
64
|
+
? content
|
|
65
|
+
: new Blob([content as unknown as BlobPart]);
|
|
66
|
+
bitmap = await createImageBitmap(blob);
|
|
67
|
+
const scale = Math.min(1, ENCODE_MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
|
68
|
+
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
69
|
+
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
70
|
+
const canvas =
|
|
71
|
+
typeof OffscreenCanvas !== 'undefined'
|
|
72
|
+
? new OffscreenCanvas(width, height)
|
|
73
|
+
: typeof document !== 'undefined'
|
|
74
|
+
? Object.assign(document.createElement('canvas'), { width, height })
|
|
75
|
+
: null;
|
|
76
|
+
if (!canvas) return null;
|
|
77
|
+
const ctx = canvas.getContext('2d') as
|
|
78
|
+
| OffscreenCanvasRenderingContext2D
|
|
79
|
+
| CanvasRenderingContext2D
|
|
80
|
+
| null;
|
|
81
|
+
if (!ctx) return null;
|
|
82
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
83
|
+
const { data } = ctx.getImageData(0, 0, width, height);
|
|
84
|
+
return encode(data, width, height, options?.componentX ?? 4, options?.componentY ?? 3);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
} finally {
|
|
88
|
+
bitmap?.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { classifySyncError } from './error-classification';
|
|
3
|
+
|
|
4
|
+
describe('classifySyncError', () => {
|
|
5
|
+
it('classifies surreal ConnectionUnavailableError as network', () => {
|
|
6
|
+
// Thrown synchronously by the WS engine's send() while the socket is down
|
|
7
|
+
// but reconnect hasn't fired. Message contains "connected", not "connection".
|
|
8
|
+
const err = new Error(
|
|
9
|
+
'You must be connected to a SurrealDB instance before performing this operation'
|
|
10
|
+
);
|
|
11
|
+
expect(classifySyncError(err)).toBe('network');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('classifies surreal CallTerminatedError as network', () => {
|
|
15
|
+
const err = new Error(
|
|
16
|
+
'The call has been terminated because the connection was closed'
|
|
17
|
+
);
|
|
18
|
+
expect(classifySyncError(err)).toBe('network');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each([
|
|
22
|
+
'WebSocket connection failed',
|
|
23
|
+
'fetch failed',
|
|
24
|
+
'connect ECONNREFUSED 127.0.0.1:8666',
|
|
25
|
+
'request timed out',
|
|
26
|
+
'The operation was aborted',
|
|
27
|
+
'socket hang up',
|
|
28
|
+
])('classifies %j as network', (message) => {
|
|
29
|
+
expect(classifySyncError(new Error(message))).toBe('network');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it.each([
|
|
33
|
+
'Permission denied',
|
|
34
|
+
'There was a problem with the database: record already exists',
|
|
35
|
+
'Parse error: unexpected token',
|
|
36
|
+
])('classifies %j as application', (message) => {
|
|
37
|
+
expect(classifySyncError(new Error(message))).toBe('application');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('handles non-Error values', () => {
|
|
41
|
+
expect(classifySyncError('connection reset')).toBe('network');
|
|
42
|
+
expect(classifySyncError('boom')).toBe('application');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
const NETWORK_ERROR_PATTERNS = [
|
|
2
2
|
'connection',
|
|
3
|
+
// surreal's ConnectionUnavailableError reads "You must be connected to a
|
|
4
|
+
// SurrealDB instance..." — it contains "connected", not "connection", so it
|
|
5
|
+
// slips past the pattern above. The WS engine throws it synchronously from
|
|
6
|
+
// `send()` while the socket is down but reconnect hasn't fired yet, so it's the
|
|
7
|
+
// canonical error on an idle-dropped socket; classify it as network.
|
|
8
|
+
'must be connected',
|
|
9
|
+
'connectionunavailable',
|
|
3
10
|
'timeout',
|
|
4
11
|
'timed out',
|
|
5
12
|
'websocket',
|
package/src/utils/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { GetTable, SchemaStructure, TableModel, TableNames } from '@spooky-sync/query-builder';
|
|
1
|
+
import type { GetTable, SchemaStructure, TableModel, TableNames } from '@spooky-sync/query-builder';
|
|
2
2
|
import { Uuid, RecordId, Duration } from 'surrealdb';
|
|
3
|
-
import { Logger } from '../services/logger/index';
|
|
4
|
-
import { QueryTimeToLive } from '../types';
|
|
3
|
+
import type { Logger } from '../services/logger/index';
|
|
4
|
+
import type { QueryTimeToLive } from '../types';
|
|
5
5
|
|
|
6
6
|
export * from './surql';
|
|
7
7
|
export * from './parser';
|
|
@@ -59,7 +59,7 @@ export function generateNewTableId<S extends SchemaStructure, T extends TableNam
|
|
|
59
59
|
|
|
60
60
|
// ==================== SCHEMA ENCODING/DECODING ====================
|
|
61
61
|
|
|
62
|
-
export function
|
|
62
|
+
export function decodeFromSp00ky<S extends SchemaStructure, T extends TableNames<S>>(
|
|
63
63
|
schema: S,
|
|
64
64
|
tableName: T,
|
|
65
65
|
record: TableModel<GetTable<S, T>>
|
|
@@ -74,19 +74,19 @@ export function decodeFromSpooky<S extends SchemaStructure, T extends TableNames
|
|
|
74
74
|
for (const field of Object.keys(table.columns)) {
|
|
75
75
|
const column = table.columns[field] as any;
|
|
76
76
|
const relation = schema.relationships.find((r) => r.from === tableName && r.field === field);
|
|
77
|
-
if ((column.recordId || relation) && encoded[field]
|
|
77
|
+
if ((column.recordId || relation) && encoded[field] !== null && encoded[field] !== undefined) {
|
|
78
78
|
if (encoded[field] instanceof RecordId) {
|
|
79
79
|
encoded[field] = `${encoded[field].table.toString()}:${encoded[field].id}`;
|
|
80
80
|
} else if (
|
|
81
81
|
relation &&
|
|
82
|
-
(encoded[field] instanceof Object || encoded[field]
|
|
82
|
+
(encoded[field] instanceof Object || Array.isArray(encoded[field]))
|
|
83
83
|
) {
|
|
84
84
|
if (Array.isArray(encoded[field])) {
|
|
85
85
|
encoded[field] = encoded[field].map((item) =>
|
|
86
|
-
|
|
86
|
+
decodeFromSp00ky(schema, relation.to, item)
|
|
87
87
|
);
|
|
88
88
|
} else {
|
|
89
|
-
encoded[field] =
|
|
89
|
+
encoded[field] = decodeFromSp00ky(schema, relation.to, encoded[field]);
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
}
|
|
@@ -97,15 +97,25 @@ export function decodeFromSpooky<S extends SchemaStructure, T extends TableNames
|
|
|
97
97
|
|
|
98
98
|
// ==================== TIME/DURATION UTILITIES ====================
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
|
|
102
|
+
* releases expose it under `milliseconds` (current) or the older private
|
|
103
|
+
* `_milliseconds` field, so read whichever is set; returns 0 when neither is.
|
|
104
|
+
*/
|
|
105
|
+
function durationMillis(duration: Duration): number {
|
|
106
|
+
const d = duration as { milliseconds?: number | bigint; _milliseconds?: number | bigint };
|
|
107
|
+
return Number(d.milliseconds || d._milliseconds || 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
/**
|
|
101
111
|
* Parse duration string or Duration object to milliseconds
|
|
102
112
|
*/
|
|
103
113
|
export function parseDuration(duration: QueryTimeToLive | Duration): number {
|
|
104
114
|
if (duration instanceof Duration) {
|
|
105
|
-
const ms = (duration
|
|
106
|
-
if (ms) return
|
|
115
|
+
const ms = durationMillis(duration);
|
|
116
|
+
if (ms) return ms;
|
|
107
117
|
const str = duration.toString();
|
|
108
|
-
if (str !== '[object Object]') return parseDuration(str as
|
|
118
|
+
if (str !== '[object Object]') return parseDuration(str as QueryTimeToLive);
|
|
109
119
|
return 600000;
|
|
110
120
|
}
|
|
111
121
|
|
|
@@ -117,7 +127,7 @@ export function parseDuration(duration: QueryTimeToLive | Duration): number {
|
|
|
117
127
|
|
|
118
128
|
const match = duration.match(/^(\d+)([smh])$/);
|
|
119
129
|
if (!match) return 600000;
|
|
120
|
-
const val = parseInt(match[1], 10);
|
|
130
|
+
const val = Number.parseInt(match[1], 10);
|
|
121
131
|
const unit = match[2];
|
|
122
132
|
switch (unit) {
|
|
123
133
|
case 's':
|
|
@@ -137,6 +147,18 @@ export async function fileToUint8Array(file: File | Blob): Promise<Uint8Array> {
|
|
|
137
147
|
return new Uint8Array(buffer);
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
// ==================== TEXT UTILITIES ====================
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Convert plain text to simple HTML paragraphs.
|
|
154
|
+
* Useful for seeding a rich-text editor (e.g. TipTap/ProseMirror) with fallback content.
|
|
155
|
+
*/
|
|
156
|
+
export function textToHtml(text: string): string {
|
|
157
|
+
return text
|
|
158
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
159
|
+
.split('\n').map((l) => `<p>${l || '<br>'}</p>`).join('');
|
|
160
|
+
}
|
|
161
|
+
|
|
140
162
|
// ==================== DATABASE UTILITIES ====================
|
|
141
163
|
|
|
142
164
|
/**
|
|
@@ -154,6 +176,9 @@ export async function withRetry<T>(
|
|
|
154
176
|
return await operation();
|
|
155
177
|
} catch (err: any) {
|
|
156
178
|
lastError = err;
|
|
179
|
+
// A deadline expiry is explicitly not retryable: the op is still running
|
|
180
|
+
// in the engine, and a retry only queues a second copy behind it.
|
|
181
|
+
if (err?.retryable === false) throw err;
|
|
157
182
|
if (
|
|
158
183
|
err?.message?.includes('Can not open transaction') ||
|
|
159
184
|
err?.message?.includes('transaction') ||
|
|
@@ -165,7 +190,7 @@ export async function withRetry<T>(
|
|
|
165
190
|
attempt: i + 1,
|
|
166
191
|
retries,
|
|
167
192
|
error: msg,
|
|
168
|
-
Category: '
|
|
193
|
+
Category: 'sp00ky-client::utils::withRetry',
|
|
169
194
|
},
|
|
170
195
|
'Retrying DB operation'
|
|
171
196
|
);
|
|
@@ -177,3 +202,44 @@ export async function withRetry<T>(
|
|
|
177
202
|
}
|
|
178
203
|
throw lastError;
|
|
179
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Reject after `timeoutMs` if `promise` hasn't settled.
|
|
208
|
+
*
|
|
209
|
+
* Exists because a WebSocket RPC has no deadline of its own: the SurrealDB SDK
|
|
210
|
+
* parks each call in a pending map and only rejects it when the socket reports
|
|
211
|
+
* a close. On a half-open socket (peer gone, no `close` event, `readyState`
|
|
212
|
+
* still OPEN) the call never settles at all, which wedges anything serialized
|
|
213
|
+
* behind it.
|
|
214
|
+
*
|
|
215
|
+
* `message` MUST contain "timed out" so `classifySyncError` treats the
|
|
216
|
+
* rejection as `network` — that's what makes the sync queues retry the
|
|
217
|
+
* operation instead of rolling the mutation back as an application error.
|
|
218
|
+
*
|
|
219
|
+
* Non-positive `timeoutMs` returns `promise` unchanged. The underlying promise
|
|
220
|
+
* is NOT cancelled (nothing can cancel an in-flight RPC); its eventual
|
|
221
|
+
* settlement is absorbed, so attach no expectations to it after a timeout.
|
|
222
|
+
*/
|
|
223
|
+
export function withTimeout<T>(
|
|
224
|
+
promise: Promise<T>,
|
|
225
|
+
timeoutMs: number,
|
|
226
|
+
message: string | (() => Error)
|
|
227
|
+
): Promise<T> {
|
|
228
|
+
if (!(timeoutMs > 0)) return promise;
|
|
229
|
+
return new Promise<T>((resolve, reject) => {
|
|
230
|
+
const timer = setTimeout(
|
|
231
|
+
() => reject(typeof message === 'function' ? message() : new Error(message)),
|
|
232
|
+
timeoutMs
|
|
233
|
+
);
|
|
234
|
+
promise.then(
|
|
235
|
+
(value) => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
resolve(value);
|
|
238
|
+
},
|
|
239
|
+
(err) => {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
reject(err);
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
}
|
package/src/utils/parser.test.ts
CHANGED
|
@@ -1,125 +1,54 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import { RecordId } from '
|
|
3
|
-
import { parseParams,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const schema = {
|
|
45
|
-
createdAt: { type: 'string' as const, optional: false, dateTime: true },
|
|
46
|
-
};
|
|
47
|
-
const ts = 1704067200000; // 2024-01-01T00:00:00.000Z
|
|
48
|
-
const result = parseParams(schema, { createdAt: ts });
|
|
49
|
-
expect(result.createdAt).toBeInstanceOf(Date);
|
|
50
|
-
expect(result.createdAt.getTime()).toBe(ts);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('passes through existing Date for dateTime columns', () => {
|
|
54
|
-
const schema = {
|
|
55
|
-
createdAt: { type: 'string' as const, optional: false, dateTime: true },
|
|
56
|
-
};
|
|
57
|
-
const date = new Date('2024-01-01');
|
|
58
|
-
const result = parseParams(schema, { createdAt: date });
|
|
59
|
-
expect(result.createdAt).toBe(date);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('skips undefined values', () => {
|
|
63
|
-
const schema = {
|
|
64
|
-
name: { type: 'string' as const, optional: false },
|
|
65
|
-
age: { type: 'number' as const, optional: true },
|
|
66
|
-
};
|
|
67
|
-
const result = parseParams(schema, { name: 'Alice', age: undefined });
|
|
68
|
-
expect(result).toEqual({ name: 'Alice' });
|
|
69
|
-
expect('age' in result).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('throws on invalid recordId value', () => {
|
|
73
|
-
const schema = {
|
|
74
|
-
owner: { type: 'string' as const, optional: false, recordId: true },
|
|
75
|
-
};
|
|
76
|
-
expect(() => parseParams(schema, { owner: 12345 })).toThrow('Invalid value for owner');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('throws on invalid dateTime value', () => {
|
|
80
|
-
const schema = {
|
|
81
|
-
createdAt: { type: 'string' as const, optional: false, dateTime: true },
|
|
82
|
-
};
|
|
83
|
-
expect(() => parseParams(schema, { createdAt: true })).toThrow(
|
|
84
|
-
'Invalid value for createdAt'
|
|
85
|
-
);
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from '@spooky-sync/query-builder';
|
|
3
|
+
import { parseParams, parseQueryParams } from './parser';
|
|
4
|
+
|
|
5
|
+
const columns = {
|
|
6
|
+
white: { recordId: true },
|
|
7
|
+
black: { recordId: true },
|
|
8
|
+
owner: { recordId: true },
|
|
9
|
+
created_at: { dateTime: true },
|
|
10
|
+
result: {},
|
|
11
|
+
} as any;
|
|
12
|
+
|
|
13
|
+
describe('parseQueryParams', () => {
|
|
14
|
+
// The regression this exists for: an `_or` group binds under synthetic names,
|
|
15
|
+
// and dropping them left `(white = $white__or0 OR black = $black__or1)` with
|
|
16
|
+
// nothing bound, so every OR query registered and matched no rows at all.
|
|
17
|
+
it('keeps an _or branch param and types it through its field', () => {
|
|
18
|
+
const parsed = parseQueryParams(columns, {
|
|
19
|
+
owner: 'user:abc',
|
|
20
|
+
white__or0: 'player_name:PN_1',
|
|
21
|
+
black__or1: 'player_name:PN_1',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(Object.keys(parsed).sort()).toEqual(['black__or1', 'owner', 'white__or0']);
|
|
25
|
+
expect(parsed.white__or0).toBeInstanceOf(RecordId);
|
|
26
|
+
expect(String(parsed.white__or0)).toBe('player_name:PN_1');
|
|
27
|
+
expect(parsed.black__or1).toBeInstanceOf(RecordId);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('passes a param it cannot resolve through untouched instead of dropping it', () => {
|
|
31
|
+
const parsed = parseQueryParams(columns, { in: 'anything', limit: 30 });
|
|
32
|
+
expect(parsed).toEqual({ in: 'anything', limit: 30 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('still types and keeps plain column params, and skips undefined', () => {
|
|
36
|
+
const parsed = parseQueryParams(columns, {
|
|
37
|
+
owner: 'user:abc',
|
|
38
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
39
|
+
result: undefined,
|
|
40
|
+
});
|
|
41
|
+
expect(parsed.owner).toBeInstanceOf(RecordId);
|
|
42
|
+
expect(parsed.created_at).toBeInstanceOf(Date);
|
|
43
|
+
expect('result' in parsed).toBe(false);
|
|
86
44
|
});
|
|
87
45
|
});
|
|
88
46
|
|
|
89
|
-
describe('
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
it('keeps schema fields and strips non-schema fields', () => {
|
|
96
|
-
const record = { id: 'user:1', name: 'Alice', age: 30, _internal: true, computed_score: 99 };
|
|
97
|
-
const result = cleanRecord(tableSchema, record);
|
|
98
|
-
expect(result).toEqual({ id: 'user:1', name: 'Alice', age: 30 });
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('always preserves id even though it is not in schema', () => {
|
|
102
|
-
const record = { id: 'user:2', extra: 'gone' };
|
|
103
|
-
const result = cleanRecord(tableSchema, record);
|
|
104
|
-
expect(result).toEqual({ id: 'user:2' });
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('does not coerce or transform values', () => {
|
|
108
|
-
const date = new Date('2024-01-01');
|
|
109
|
-
const schema = { created: { type: 'string' as const, optional: false, dateTime: true } };
|
|
110
|
-
const record = { id: 'x:1', created: date };
|
|
111
|
-
const result = cleanRecord(schema, record);
|
|
112
|
-
expect(result.created).toBe(date);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('returns only id when record has no schema fields', () => {
|
|
116
|
-
const record = { id: 'user:3', unknown1: 'a', unknown2: 'b' };
|
|
117
|
-
const result = cleanRecord(tableSchema, record);
|
|
118
|
-
expect(result).toEqual({ id: 'user:3' });
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('handles empty record', () => {
|
|
122
|
-
const result = cleanRecord(tableSchema, {});
|
|
123
|
-
expect(result).toEqual({});
|
|
47
|
+
describe('parseParams', () => {
|
|
48
|
+
// Records keep the old behaviour: a field the schema does not know is dropped
|
|
49
|
+
// rather than written.
|
|
50
|
+
it('drops a field that is not a column', () => {
|
|
51
|
+
const parsed = parseParams(columns, { result: '1-0', nonsense: 'x', white__or0: 'y' });
|
|
52
|
+
expect(parsed).toEqual({ result: '1-0' });
|
|
124
53
|
});
|
|
125
54
|
});
|
package/src/utils/parser.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ColumnSchema
|
|
1
|
+
import type { ColumnSchema} from '@spooky-sync/query-builder';
|
|
2
|
+
import { RecordId, baseFieldOfParam } from '@spooky-sync/query-builder';
|
|
2
3
|
import { parseRecordIdString } from './index';
|
|
3
4
|
import { DateTime } from 'surrealdb';
|
|
4
5
|
|
|
@@ -8,13 +9,17 @@ export function cleanRecord(
|
|
|
8
9
|
): Record<string, any> {
|
|
9
10
|
const cleaned: Record<string, any> = {};
|
|
10
11
|
for (const [key, value] of Object.entries(record)) {
|
|
11
|
-
if (key === 'id' || key in tableSchema) {
|
|
12
|
+
if (key === 'id' || key.startsWith('_00_') || key in tableSchema) {
|
|
12
13
|
cleaned[key] = value;
|
|
13
14
|
}
|
|
14
15
|
}
|
|
15
16
|
return cleaned;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Parse a RECORD's fields against the table schema. Anything the schema does not
|
|
21
|
+
* know is dropped, which is what keeps a stray field out of a write.
|
|
22
|
+
*/
|
|
18
23
|
export function parseParams(
|
|
19
24
|
tableSchema: Record<string, ColumnSchema>,
|
|
20
25
|
params: Record<string, any>
|
|
@@ -30,6 +35,31 @@ export function parseParams(
|
|
|
30
35
|
return parsedParams;
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Parse a QUERY's params. Unlike a record's fields, a param name is not always a
|
|
40
|
+
* column: an `_or` branch binds under a synthetic `white__or0` (see
|
|
41
|
+
* `orParamName` in @spooky-sync/query-builder), and the surql that references it
|
|
42
|
+
* is already written. So resolve the column through the synthetic name, and keep
|
|
43
|
+
* a param we cannot type rather than dropping it.
|
|
44
|
+
*
|
|
45
|
+
* Dropping was the bug: `parseParams` kept only column-named params, so every
|
|
46
|
+
* `_or` query registered with `$or0`/`$or1` unbound and matched no rows at all,
|
|
47
|
+
* silently (rowCount 0, errorCount 0).
|
|
48
|
+
*/
|
|
49
|
+
export function parseQueryParams(
|
|
50
|
+
tableSchema: Record<string, ColumnSchema>,
|
|
51
|
+
params: Record<string, any>
|
|
52
|
+
) {
|
|
53
|
+
const parsedParams: Record<string, any> = {};
|
|
54
|
+
for (const [key, value] of Object.entries(params)) {
|
|
55
|
+
if (value === undefined) continue;
|
|
56
|
+
const column = tableSchema[key] ?? tableSchema[baseFieldOfParam(key)];
|
|
57
|
+
parsedParams[key] = column ? parseValue(key, column, value) : value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return parsedParams;
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
function parseValue(name: string, column: ColumnSchema, value: any) {
|
|
34
64
|
if (column.recordId) {
|
|
35
65
|
if (value instanceof RecordId) return value;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { semverGt } from './semver';
|
|
3
|
+
|
|
4
|
+
describe('semverGt', () => {
|
|
5
|
+
it('compares strictly greater', () => {
|
|
6
|
+
expect(semverGt('1.0.1', '1.0.0')).toBe(true);
|
|
7
|
+
expect(semverGt('1.1.0', '1.0.9')).toBe(true);
|
|
8
|
+
expect(semverGt('2.0.0', '1.9.9')).toBe(true);
|
|
9
|
+
expect(semverGt('1.10.0', '1.9.0')).toBe(true); // numeric, not lexicographic
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('equal and lower are not greater', () => {
|
|
13
|
+
expect(semverGt('1.0.0', '1.0.0')).toBe(false);
|
|
14
|
+
expect(semverGt('1.0.0', '1.0.1')).toBe(false);
|
|
15
|
+
expect(semverGt('0.9.9', '1.0.0')).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('missing parts default to zero', () => {
|
|
19
|
+
expect(semverGt('1.2', '1.2.0')).toBe(false);
|
|
20
|
+
expect(semverGt('1.2.1', '1.2')).toBe(true);
|
|
21
|
+
expect(semverGt('2', '1.9.9')).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('junk never compares greater', () => {
|
|
25
|
+
expect(semverGt('abc', '1.0.0')).toBe(false);
|
|
26
|
+
expect(semverGt('1.0.0', 'abc')).toBe(false);
|
|
27
|
+
expect(semverGt('', '0.0.0')).toBe(false);
|
|
28
|
+
expect(semverGt(null, undefined)).toBe(false);
|
|
29
|
+
expect(semverGt('1.0.0-beta', '0.9.0')).toBe(false); // prerelease unsupported
|
|
30
|
+
expect(semverGt('1.2.3.4', '1.0.0')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Minimal semver comparison for app-release version checks. "X.Y.Z" numeric
|
|
2
|
+
// with missing parts read as 0 ("1.2" == "1.2.0"); any malformed input never
|
|
3
|
+
// compares greater, so a bad release row can never nag (or force-reload)
|
|
4
|
+
// every client.
|
|
5
|
+
|
|
6
|
+
function parse(v: unknown): [number, number, number] | null {
|
|
7
|
+
const parts = String(v ?? '')
|
|
8
|
+
.trim()
|
|
9
|
+
.split('.');
|
|
10
|
+
if (parts.length === 0 || parts.length > 3 || parts[0] === '') return null;
|
|
11
|
+
const nums: number[] = [];
|
|
12
|
+
for (let i = 0; i < 3; i++) {
|
|
13
|
+
const raw = parts[i] ?? '0';
|
|
14
|
+
if (!/^\d+$/.test(raw)) return null;
|
|
15
|
+
nums.push(parseInt(raw, 10));
|
|
16
|
+
}
|
|
17
|
+
return nums as [number, number, number];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when `a` is a valid version strictly greater than valid version `b`. */
|
|
21
|
+
export function semverGt(a: unknown, b: unknown): boolean {
|
|
22
|
+
const pa = parse(a);
|
|
23
|
+
const pb = parse(b);
|
|
24
|
+
if (!pa || !pb) return false;
|
|
25
|
+
for (let i = 0; i < 3; i++) {
|
|
26
|
+
if (pa[i] > pb[i]) return true;
|
|
27
|
+
if (pa[i] < pb[i]) return false;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|