@doync/client 0.1.0

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/adapter.cjs +1 -0
  4. package/dist/adapter.d.cts +86 -0
  5. package/dist/adapter.d.cts.map +1 -0
  6. package/dist/adapter.d.ts +86 -0
  7. package/dist/adapter.d.ts.map +1 -0
  8. package/dist/adapter.js +2 -0
  9. package/dist/adapter.js.map +1 -0
  10. package/dist/client-C6jAdhbe.cjs +15 -0
  11. package/dist/client-CNyLMCw0.d.ts +812 -0
  12. package/dist/client-CNyLMCw0.d.ts.map +1 -0
  13. package/dist/client-ClV8ce6X.js +16 -0
  14. package/dist/client-ClV8ce6X.js.map +1 -0
  15. package/dist/client-DHXO0dbf.d.cts +812 -0
  16. package/dist/client-DHXO0dbf.d.cts.map +1 -0
  17. package/dist/index.cjs +0 -0
  18. package/dist/index.d.cts +2 -0
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +0 -0
  21. package/dist/internal.cjs +1 -0
  22. package/dist/internal.d.cts +69 -0
  23. package/dist/internal.d.cts.map +1 -0
  24. package/dist/internal.d.ts +69 -0
  25. package/dist/internal.d.ts.map +1 -0
  26. package/dist/internal.js +2 -0
  27. package/dist/internal.js.map +1 -0
  28. package/package.json +79 -0
  29. package/src/adapter.ts +25 -0
  30. package/src/client-mutation-registry.ts +31 -0
  31. package/src/client.ts +100 -0
  32. package/src/engine.ts +3322 -0
  33. package/src/identity.ts +41 -0
  34. package/src/index.ts +36 -0
  35. package/src/internal.ts +37 -0
  36. package/src/migrate.ts +156 -0
  37. package/src/mutations.ts +96 -0
  38. package/src/port.ts +74 -0
  39. package/src/raw-read.ts +135 -0
  40. package/src/replica/db/0000_replica_engine_v0.sql +20 -0
  41. package/src/replica/db/0001_release_stamps.sql +6 -0
  42. package/src/replica/db/meta/0000_snapshot.json +129 -0
  43. package/src/replica/db/meta/0001_snapshot.json +167 -0
  44. package/src/replica/db/meta/_journal.json +20 -0
  45. package/src/replica/db/schema.ts +67 -0
  46. package/src/replica/index.ts +185 -0
  47. package/src/replica/meta.ts +26 -0
  48. package/src/replica/stamps.ts +102 -0
  49. package/src/replica/track.ts +190 -0
  50. package/src/socket-reconnect.ts +242 -0
  51. package/src/socket.ts +44 -0
  52. package/src/sql-raw.d.ts +4 -0
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Per-(Database name, identity) replica filename (CONTEXT.md Database name;
3
+ * ADR-0019 addendum / closeio/doync#187 / #321) — a PURE helper shared by every
4
+ * platform that owns a durable replica file (web OPFS, mobile op-sqlite, …).
5
+ *
6
+ * The file key is `(name, userId)`: two Database names in one app never collide
7
+ * on the same store, and each identity (`doync__<name>__<userId|anon>.db`) owns
8
+ * its own replica, clientId, cookie, and pending queue so a shared device never
9
+ * leaks one user's rows into another's session. The id is percent-encoded so an
10
+ * exotic identity can never escape the filename (a `/` would otherwise mean a
11
+ * subpath); a numeric/alphanumeric id (the common case, e.g. a GitHub id)
12
+ * encodes to itself, keeping the file readable. Pre-release: no migration of
13
+ * pre-#187 / pre-#321 filenames — wipe/redeploy owns the cutover.
14
+ */
15
+
16
+ /**
17
+ * Encode one half of the replica filename so it cannot contain the format's
18
+ * `__` separator and cannot form a path segment (`/`, etc.).
19
+ *
20
+ * `encodeURIComponent` leaves `_` intact; escaping `_` → `%5F` keeps `(name:
21
+ * 'a__b', user: 'c')` from colliding with `(name: 'a', user: 'b__c')`.
22
+ * UUID-shaped names (hyphens only) encode to themselves.
23
+ */
24
+ function encodeHalf(value: string): string {
25
+ return encodeURIComponent(value).replaceAll('_', '%5F')
26
+ }
27
+
28
+ /**
29
+ * Durable replica file for one `(Database name, asserted userId)` pair:
30
+ * `doync__<name>__anon.db` when `userId` is `null`, else
31
+ * `doync__<name>__<userId>.db`. Separators are `__` so hyphens (common in UUIDs
32
+ * and ids) stay unescaped and filenames stay short enough for OPFS. Each half
33
+ * is percent-encoded; `_` is additionally escaped so halves cannot forge a
34
+ * separator.
35
+ */
36
+ export function dbFileForUserId(name: string, userId: string | null): string {
37
+ const encodedName = encodeHalf(name)
38
+ return userId === null
39
+ ? `doync__${encodedName}__anon.db`
40
+ : `doync__${encodedName}__${encodeHalf(userId)}.db`
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `@doync/client` public surface (ADR-0033): the app-facing call-surface types
3
+ * adapters wrap (`DoyncClient`, views, mutation types, connection + schema
4
+ * status, `LogoutBehavior`). Nothing callable remains on this entry —
5
+ * construction is `@doync/client/adapter` (`createClient`) or
6
+ * `@doync/client/internal` (`createClientEngine`). The real consumer packages
7
+ * are `@doync/web`, `@doync/mobile`, and `@doync/react`.
8
+ *
9
+ * Host seams, reconnect, and `createClient` live under `./adapter`. Engine
10
+ * construction internals and mutation-registry helpers live under
11
+ * `./internal`.
12
+ */
13
+
14
+ export type { LogoutBehavior } from './client'
15
+ export type {
16
+ ConnectionStatus,
17
+ DoyncClient,
18
+ FalsyQuery,
19
+ MutationOptions,
20
+ MutationResult,
21
+ OnceView,
22
+ PreloadHandle,
23
+ PreloadOptions,
24
+ QueryStatus,
25
+ SchemaEvent,
26
+ SchemaEventKind,
27
+ SubscribeOptions,
28
+ View,
29
+ ViewStatus,
30
+ } from './engine'
31
+ export type {
32
+ ClientMutation,
33
+ ClientMutationHandler,
34
+ ClientMutationRegistry,
35
+ ClientMutationTx,
36
+ } from './mutations'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `@doync/client/internal` (ADR-0033): engine construction, replica DDL, meta
3
+ * keys, mutation flattening, surface-normalize helpers, and mutation-registry
4
+ * dispatch plumbing. May break in any release. App-facing call-surface types
5
+ * live on the main entry (`@doync/client`); host seams / reconnect /
6
+ * `createClient` live on `@doync/client/adapter` — import those from their
7
+ * entries, not here (no double-listing).
8
+ */
9
+
10
+ export { createClientEngine } from './client'
11
+ export { toClientMutationRegistry } from './client-mutation-registry'
12
+ export {
13
+ ClientEngine,
14
+ __CONNECTED_CLOCK_META_KEY,
15
+ __LOGOUT_BEHAVIOR_META_KEY,
16
+ settledRejection,
17
+ isFalsyQuery,
18
+ normalizeQuerySurface,
19
+ type ClientEngineConfig,
20
+ } from './engine'
21
+ export { dbFileForUserId } from './identity'
22
+ export {
23
+ bundledSchemaDdl,
24
+ classifyClientStatement,
25
+ splitClientStatements,
26
+ type ClientStatementKind,
27
+ } from './migrate'
28
+ export { asClientMutation, validateMutationArgs } from './mutations'
29
+ export { extractWriteTable } from './port'
30
+ export {
31
+ applyBundledMigrations,
32
+ createEngineTables,
33
+ dropConsumerTables,
34
+ readMeta,
35
+ replayMigrations,
36
+ writeMeta,
37
+ } from './replica'
package/src/migrate.ts ADDED
@@ -0,0 +1,156 @@
1
+ import type { DoyncSchema } from '@doync/core'
2
+
3
+ import {
4
+ parse,
5
+ parseAll,
6
+ SQLiteParserError,
7
+ unparse,
8
+ type Statement,
9
+ } from '@doync/sqlite-parser'
10
+
11
+ /**
12
+ * Parser-backed SQL classification for client DDL-only bundled-track replay
13
+ * (ADR-0020 / ADR-0006). Same AST as the server conversation, narrower API: 1.
14
+ * split migrations keeping trigger BEGIN…END whole; 2. ddl vs trigger (skip —
15
+ * apply state, never enforce; ADR-0009/0020) vs backfill DML (skip — data via
16
+ * feed; ADR-0006). Fails closed on unparseable input.
17
+ */
18
+
19
+ /** How the client treats one already-split migration statement. */
20
+ export type ClientStatementKind = 'ddl' | 'trigger' | 'dml' | 'pragma' | 'other'
21
+
22
+ /** Statement kinds recorded as replayable schema DDL (shape, not triggers). */
23
+ const DDL_KINDS = new Set<Statement['kind']>([
24
+ 'STMT_CREATE_TABLE',
25
+ 'STMT_CREATE_INDEX',
26
+ 'STMT_CREATE_VIEW',
27
+ 'STMT_CREATE_VTABLE',
28
+ 'STMT_DROP',
29
+ 'STMT_ALTER',
30
+ ])
31
+
32
+ /**
33
+ * Statement kinds that read or write rows. Backfill DML is never replayed on
34
+ * the client — data arrives through the feed (ADR-0006).
35
+ */
36
+ const DML_KINDS = new Set<Statement['kind']>([
37
+ 'STMT_INSERT',
38
+ 'STMT_UPDATE',
39
+ 'STMT_DELETE',
40
+ 'STMT_SELECT',
41
+ 'COMPOUND_SELECT',
42
+ ])
43
+
44
+ /**
45
+ * Parse one statement, or `null` when the input is unparseable or empty. Only a
46
+ * parser error maps to `null`; anything else propagates. `parse` validates the
47
+ * whole input and returns the FIRST statement (extras are dropped), so a string
48
+ * that begins with a valid statement classifies by that statement.
49
+ */
50
+ function parseFirstOrNull(statement: string): Statement | null {
51
+ try {
52
+ return parse(statement)
53
+ } catch (error) {
54
+ if (error instanceof SQLiteParserError) return null
55
+ throw error
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Parse a whole script into its statements, or `null` on a parser error (empty
61
+ * input yields an empty list, never `null`). Only a parser error maps to
62
+ * `null`; anything else propagates — the one place the parse-failure decision
63
+ * lives.
64
+ */
65
+ function parseAllOrNull(sql: string): Statement[] | null {
66
+ try {
67
+ return parseAll(sql)
68
+ } catch (error) {
69
+ if (error instanceof SQLiteParserError) return null
70
+ throw error
71
+ }
72
+ }
73
+
74
+ /** Map a parsed statement to the client's kind bucket. */
75
+ function classOf(statement: Statement): ClientStatementKind {
76
+ // Triggers first — clients never install/drop them (ADR-0009/0020).
77
+ if (statement.kind === 'STMT_CREATE_TRIGGER') return 'trigger'
78
+ if (statement.kind === 'STMT_DROP' && statement.target === 'TRIGGER') {
79
+ return 'trigger'
80
+ }
81
+ if (statement.kind === 'STMT_PRAGMA') return 'pragma'
82
+ // Remaining DDL is shape-replayable (CREATE TRIGGER not in DDL_KINDS).
83
+ if (DDL_KINDS.has(statement.kind)) return 'ddl'
84
+ if (DML_KINDS.has(statement.kind)) return 'dml'
85
+ return 'other'
86
+ }
87
+
88
+ /**
89
+ * Split a migration's SQL into its top-level statements. The parser understands
90
+ * statement structure, so a `CREATE TRIGGER … BEGIN … END` body's internal
91
+ * semicolons stay inside one statement — a migration may mix a trigger with
92
+ * surrounding DDL without mis-splitting. Each statement is returned as its
93
+ * canonical unparse (semantically identical to the source); statements that are
94
+ * empty (whitespace/comments only) yield nothing.
95
+ *
96
+ * Fails closed: an unparseable script throws a clear error rather than applying
97
+ * a partial or mis-read migration. Trusted input only in the sense that the
98
+ * consumer authored the migrations — the parse itself validates their syntax.
99
+ */
100
+ export function splitClientStatements(sql: string): string[] {
101
+ const statements = parseAllOrNull(sql)
102
+ if (statements === null) {
103
+ throw new Error(
104
+ 'could not parse the SQL — a statement has a syntax error; fix it and retry',
105
+ )
106
+ }
107
+ return statements.map((statement) => unparse(statement))
108
+ }
109
+
110
+ /**
111
+ * Classify ONE already-split statement by its parsed kind. `CREATE TRIGGER` and
112
+ * `DROP TRIGGER` are `'trigger'` (the client neither creates nor drops triggers
113
+ * — it runs none, ADR-0009); every other shape-changing DDL is `'ddl'`
114
+ * (replayed); the row read/write verbs are `'dml'` (backfill, never replayed on
115
+ * the client); `PRAGMA` and anything unrecognized/unparseable land in their own
116
+ * buckets.
117
+ */
118
+ export function classifyClientStatement(
119
+ statement: string,
120
+ ): ClientStatementKind {
121
+ const parsed = parseFirstOrNull(statement)
122
+ return parsed === null ? 'other' : classOf(parsed)
123
+ }
124
+
125
+ /**
126
+ * The non-trigger DDL statements of the bundled migrations in the half-open
127
+ * range `(fromVersion, toVersion]` (0-based migration indices `fromVersion …
128
+ * toVersion − 1`), in order — the replayable shape for the client's DDL-only
129
+ * bundled-track replay (ADR-0020). Trigger DDL is skipped (ADR-0009) and
130
+ * backfill DML never runs on the client (ADR-0006). Passing `fromVersion = 0`
131
+ * yields the full track up to `toVersion` (a fresh replica); a higher
132
+ * `fromVersion` yields just the newly-appended migrations (a mid-session
133
+ * catch-up).
134
+ */
135
+ export function bundledSchemaDdl(
136
+ schema: DoyncSchema,
137
+ fromVersion: number,
138
+ toVersion: number,
139
+ ): string[] {
140
+ const ddl: string[] = []
141
+ for (let version = fromVersion; version < toVersion; version += 1) {
142
+ const migration = schema.migrations[version]
143
+ if (migration === undefined) {
144
+ // Dense track — hole or past-end means corrupt/truncated; fail loud.
145
+ throw new Error(
146
+ `doync: bundled migration track has a hole at version ${version} ` +
147
+ `(requested range ${fromVersion}..${toVersion}, but the track holds ` +
148
+ `${schema.migrations.length}) — the schema is corrupt or truncated`,
149
+ )
150
+ }
151
+ for (const statement of splitClientStatements(migration.sql)) {
152
+ if (classifyClientStatement(statement) === 'ddl') ddl.push(statement)
153
+ }
154
+ }
155
+ return ddl
156
+ }
@@ -0,0 +1,96 @@
1
+ import type { SqlValue } from '@doync/core'
2
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
3
+
4
+ import {
5
+ assertWireRepresentable,
6
+ canonicalizeArgsBinary,
7
+ } from '@doync/core/internal'
8
+
9
+ import type { LocalRow } from './port'
10
+
11
+ /**
12
+ * Write surface a client mutation body runs against: one parameterized
13
+ * statement at a time inside the mutation's savepoint, returning its rows. Same
14
+ * shape the Origin uses, so a shared body runs identically on device and
15
+ * server.
16
+ */
17
+ export interface ClientMutationTx {
18
+ exec<T extends LocalRow = LocalRow>(query: string, ...params: SqlValue[]): T[]
19
+ }
20
+
21
+ /**
22
+ * Client mutation body `(args, ctx, sql)`. Must be deterministic and DB-only
23
+ * (may still be async for local work). Generate ids on the client and pass them
24
+ * in `args` — the body is replayed on rebase. External I/O belongs in a server
25
+ * override, not the client bundle.
26
+ */
27
+ export type ClientMutationHandler = (
28
+ args: unknown,
29
+ ctx: Record<string, unknown>,
30
+ sql: ClientMutationTx,
31
+ ) => void | Promise<void>
32
+
33
+ /**
34
+ * A registered client mutation: body plus optional standard-schema args
35
+ * validator applied at `mutate()` time. A bare handler is also accepted.
36
+ */
37
+ export interface ClientMutation {
38
+ readonly args?: StandardSchemaV1
39
+ readonly handler: ClientMutationHandler
40
+ }
41
+
42
+ /**
43
+ * Name-keyed client mutation registry. Keys match the server's dotted names so
44
+ * a push reaches the matching authoritative body.
45
+ */
46
+ export type ClientMutationRegistry = Readonly<
47
+ Record<string, ClientMutation | ClientMutationHandler>
48
+ >
49
+
50
+ /**
51
+ * Normalize a registry entry (bare handler or `{args, handler}`) to the object
52
+ * form.
53
+ */
54
+ export function asClientMutation(
55
+ entry: ClientMutation | ClientMutationHandler,
56
+ ): ClientMutation {
57
+ return typeof entry === 'function' ? { handler: entry } : entry
58
+ }
59
+
60
+ /**
61
+ * Validate `args` through a standard-schema synchronously (mirrors
62
+ * `@doync/core` query-args). Async validators refuse loudly — the body is
63
+ * deterministic on both ends. Returns validated value or throws.
64
+ */
65
+ export function validateMutationArgs(
66
+ schema: StandardSchemaV1,
67
+ args: unknown,
68
+ ): unknown {
69
+ // Unrepresentable leaves before schema (ADR-0031 / #231/#232). Binary
70
+ // allowed here; bare handlers skip this path so engine.mutate still guards
71
+ // every mutation. Double-run of guard+canonicalize on schema paths is
72
+ // intentional and cheap.
73
+ assertWireRepresentable(args, 'mutation args', { allowBinary: true })
74
+ // TypedArray/DataView → ArrayBuffer before standard-schema (ADR-0031 / #231).
75
+ const canonical = canonicalizeArgsBinary(args)
76
+ const result = schema['~standard'].validate(canonical)
77
+ if (result instanceof Promise) {
78
+ throw new Error(
79
+ 'doync: async standard-schema validation is not supported for mutation args — use a synchronous validator',
80
+ )
81
+ }
82
+ if (result.issues !== undefined) {
83
+ const detail = result.issues
84
+ .map((issue) => {
85
+ const path = (issue.path ?? [])
86
+ .map((segment) =>
87
+ typeof segment === 'object' ? String(segment.key) : String(segment),
88
+ )
89
+ .join('.')
90
+ return path === '' ? issue.message : `${path}: ${issue.message}`
91
+ })
92
+ .join('; ')
93
+ throw new Error(`doync: mutation args failed validation — ${detail}`)
94
+ }
95
+ return result.value
96
+ }
package/src/port.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { SqlValue } from '@doync/core'
2
+ import type { Statement } from '@doync/sqlite-parser'
3
+
4
+ import { parse } from '@doync/sqlite-parser'
5
+
6
+ /** One local-replica row: column name → {@link SqlValue}. */
7
+ export interface LocalRow {
8
+ [column: string]: SqlValue
9
+ }
10
+
11
+ // ADR-0019 (synchronous local-DB port; platform adapters supply the impl).
12
+ /**
13
+ * Synchronous local SQLite port the client engine drives. Platform adapters
14
+ * (wa-sqlite, node:sqlite, op-sqlite) implement this; app code does not.
15
+ * Transactions and savepoints are ordinary SQL via {@link LocalDb.exec}.
16
+ */
17
+ export interface LocalDb {
18
+ /**
19
+ * Run one parameterized statement; return its rows (empty for writes/DDL/
20
+ * transaction control).
21
+ */
22
+ exec<T extends LocalRow = LocalRow>(sql: string, ...params: SqlValue[]): T[]
23
+
24
+ /**
25
+ * Run a multi-statement DDL script (no parameters). Used to replay bundled
26
+ * migrations when creating a fresh replica. Trusted input only.
27
+ */
28
+ execBatch(script: string): void
29
+
30
+ /**
31
+ * Consumer tables written since the last call, then clear the set. Used so
32
+ * only affected subscriptions re-project after a mutation or rebase.
33
+ */
34
+ drainWrittenTables(): Set<string>
35
+ }
36
+
37
+ /**
38
+ * Extract the target table of one WRITE statement from its SQL text — the
39
+ * adapter-side stand-in where no synchronous native change feed exists
40
+ * (ADR-0019): node:sqlite has no update hook, and op-sqlite's hook delivers
41
+ * callbacks via `invokeAsync` — a later event-loop turn, unusable for the
42
+ * engine's drain-after-apply contract (closeio/doync#202 device pass).
43
+ *
44
+ * A real parse over `@doync/sqlite-parser`, not a keyword recognizer: the AST
45
+ * attaches a `WITH …` CTE prefix to the DML node itself, so CTE-topped writes
46
+ * (`WITH src AS (…) INSERT INTO t …`) extract their target with no string
47
+ * games. Returns the unquoted table name for INSERT / REPLACE / UPDATE /
48
+ * DELETE, or `null` for a non-write (SELECT, SAVEPOINT, PRAGMA, DDL) or
49
+ * unparseable input.
50
+ *
51
+ * TRIGGER cascades remain invisible to statement text — and stay out of scope
52
+ * by design: client replicas carry no triggers (the bundled-track replay strips
53
+ * CREATE TRIGGER; ADR-0009 — clients apply state, never enforce).
54
+ */
55
+ export function extractWriteTable(sql: string): string | null {
56
+ let statement: Statement
57
+ try {
58
+ statement = parse(sql)
59
+ } catch (err) {
60
+ console.warn('extractWriteTable: failed to parse SQL statement', {
61
+ sql,
62
+ err,
63
+ })
64
+ return null
65
+ }
66
+ switch (statement.kind) {
67
+ case 'STMT_INSERT':
68
+ case 'STMT_UPDATE':
69
+ case 'STMT_DELETE':
70
+ return statement.table ?? null
71
+ default:
72
+ return null
73
+ }
74
+ }
@@ -0,0 +1,135 @@
1
+ import type { SqlValue } from '@doync/core'
2
+
3
+ import type { ViewStatus } from './engine'
4
+
5
+ /**
6
+ * A resolved statement a raw read executes verbatim — the engine's INTERNAL
7
+ * currency (ADR-0021). Distinct from the wire's `DesiredQuery` (`{name, args}`,
8
+ * ADR-0016): the statement never leaves the device — the Mirror re-resolves the
9
+ * registered callback under its own verified ctx (ADR-0018).
10
+ */
11
+ export interface LocalStatement {
12
+ readonly sql: string
13
+ readonly params: readonly SqlValue[]
14
+ }
15
+
16
+ /**
17
+ * What a {@link RawRead} needs from its host, injected at construction
18
+ * (ADR-0023: raw reads receive their narrow needs and know nothing of the
19
+ * engine). `run` executes the statement over the live replica; `readSet`
20
+ * answers the subscription's server-confirmed Read-set for targeted
21
+ * re-projection, or `undefined` before the ack / for a Local read. Both are
22
+ * plain accessors so a five-line fake makes the read unit-testable.
23
+ */
24
+ export interface RawReadHost {
25
+ run(statement: LocalStatement): Record<string, SqlValue>[]
26
+ readSet(): ReadonlySet<string> | undefined
27
+ }
28
+
29
+ type AnyRow = Record<string, SqlValue>
30
+
31
+ /**
32
+ * One shared reactive read per Query instance (ADR-0023 share-raw): executes
33
+ * the statement and dedups on RAW rows. No decoder here — identity excludes
34
+ * decode/one (ADR-0021), so identical SQL flavors share this read and each
35
+ * handle decodes (Q4). generation ticks on real change.
36
+ *
37
+ * Raw rows (scalars / JSON TEXT) equal cheaply; decoded nested objects cannot.
38
+ * Status (#104) is stable until the visible status moves; pending→acked is
39
+ * invisible.
40
+ */
41
+ export class RawRead {
42
+ #raw: AnyRow[] = []
43
+ /** Bumped on real change — handles' decode-memo key. */
44
+ #generation = 0
45
+ readonly #listeners = new Set<() => void>()
46
+ #status: ViewStatus
47
+
48
+ constructor(
49
+ private readonly host: RawReadHost,
50
+ readonly statement: LocalStatement,
51
+ /** The Query instance identity; `undefined` for a Local read. */
52
+ readonly identity: string | undefined,
53
+ /** Seed status: phase, or complete for Local. */
54
+ initialStatus: ViewStatus,
55
+ ) {
56
+ this.#status = initialStatus
57
+ }
58
+
59
+ /** Subscription Read-set for targeted re-projection (via host). */
60
+ get readSet(): ReadonlySet<string> | undefined {
61
+ return this.identity === undefined ? undefined : this.host.readSet()
62
+ }
63
+
64
+ status(): ViewStatus {
65
+ return this.#status
66
+ }
67
+
68
+ updateStatus(next: ViewStatus): boolean {
69
+ if (
70
+ this.#status.status === next.status &&
71
+ this.#status.error === next.error
72
+ )
73
+ return false
74
+ this.#status = next
75
+ return true
76
+ }
77
+
78
+ /** Re-run statement; true iff raw rows moved. */
79
+ recompute(): boolean {
80
+ const rows = this.host.run(this.statement)
81
+ if (rowsEqual(rows, this.#raw)) return false
82
+ this.#raw = rows
83
+ this.#generation += 1
84
+ return true
85
+ }
86
+
87
+ /** Undecoded rows (handles decode against generation). */
88
+ rawRows(): readonly AnyRow[] {
89
+ return this.#raw
90
+ }
91
+
92
+ get generation(): number {
93
+ return this.#generation
94
+ }
95
+
96
+ onChange(listener: () => void): () => void {
97
+ this.#listeners.add(listener)
98
+ return () => this.#listeners.delete(listener)
99
+ }
100
+
101
+ notify(): void {
102
+ for (const listener of this.#listeners) listener()
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Scalar equality on raw SQL rows — keeps `current()` stable between changes
108
+ * (useSyncExternalStore).
109
+ */
110
+ export function rowsEqual(a: readonly AnyRow[], b: readonly AnyRow[]): boolean {
111
+ if (a.length !== b.length) return false
112
+ for (let i = 0; i < a.length; i++) {
113
+ const ra = a[i] as AnyRow
114
+ const rb = b[i] as AnyRow
115
+ const ka = Object.keys(ra)
116
+ if (ka.length !== Object.keys(rb).length) return false
117
+ for (const k of ka) {
118
+ if (!valuesEqual(ra[k] ?? null, rb[k] ?? null)) return false
119
+ }
120
+ }
121
+ return true
122
+ }
123
+
124
+ /** SqlValue equality: === for scalars, byte-wise BLOBs (ADR-0010). */
125
+ function valuesEqual(a: SqlValue, b: SqlValue): boolean {
126
+ if (a === b) return true
127
+ if (a instanceof ArrayBuffer && b instanceof ArrayBuffer) {
128
+ if (a.byteLength !== b.byteLength) return false
129
+ const va = new Uint8Array(a)
130
+ const vb = new Uint8Array(b)
131
+ for (let i = 0; i < va.length; i++) if (va[i] !== vb[i]) return false
132
+ return true
133
+ }
134
+ return false
135
+ }
@@ -0,0 +1,20 @@
1
+ CREATE TABLE `__doync_membership` (
2
+ `instance` text NOT NULL,
3
+ `level` integer NOT NULL,
4
+ `tbl` text NOT NULL,
5
+ `pk` text NOT NULL,
6
+ PRIMARY KEY(`instance`, `level`, `pk`)
7
+ ) WITHOUT ROWID;
8
+ --> statement-breakpoint
9
+ CREATE INDEX `__doync_membership_by_row` ON `__doync_membership` (`tbl`,`pk`);--> statement-breakpoint
10
+ CREATE TABLE `__doync_meta` (
11
+ `k` text PRIMARY KEY NOT NULL,
12
+ `v` blob
13
+ ) WITHOUT ROWID;
14
+ --> statement-breakpoint
15
+ CREATE TABLE `__doync_pending` (
16
+ `mutation_id` integer PRIMARY KEY NOT NULL,
17
+ `name` text NOT NULL,
18
+ `args` text NOT NULL,
19
+ `idem_key` text
20
+ );
@@ -0,0 +1,6 @@
1
+ CREATE TABLE `__doync_release_stamp` (
2
+ `instance` text PRIMARY KEY NOT NULL,
3
+ `cookie` integer NOT NULL,
4
+ `released_at` integer NOT NULL,
5
+ `ttl_ms` integer NOT NULL
6
+ ) WITHOUT ROWID;