@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.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/adapter.cjs +1 -0
- package/dist/adapter.d.cts +86 -0
- package/dist/adapter.d.cts.map +1 -0
- package/dist/adapter.d.ts +86 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +2 -0
- package/dist/adapter.js.map +1 -0
- package/dist/client-C6jAdhbe.cjs +15 -0
- package/dist/client-CNyLMCw0.d.ts +812 -0
- package/dist/client-CNyLMCw0.d.ts.map +1 -0
- package/dist/client-ClV8ce6X.js +16 -0
- package/dist/client-ClV8ce6X.js.map +1 -0
- package/dist/client-DHXO0dbf.d.cts +812 -0
- package/dist/client-DHXO0dbf.d.cts.map +1 -0
- package/dist/index.cjs +0 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +0 -0
- package/dist/internal.cjs +1 -0
- package/dist/internal.d.cts +69 -0
- package/dist/internal.d.cts.map +1 -0
- package/dist/internal.d.ts +69 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -0
- package/package.json +79 -0
- package/src/adapter.ts +25 -0
- package/src/client-mutation-registry.ts +31 -0
- package/src/client.ts +100 -0
- package/src/engine.ts +3322 -0
- package/src/identity.ts +41 -0
- package/src/index.ts +36 -0
- package/src/internal.ts +37 -0
- package/src/migrate.ts +156 -0
- package/src/mutations.ts +96 -0
- package/src/port.ts +74 -0
- package/src/raw-read.ts +135 -0
- package/src/replica/db/0000_replica_engine_v0.sql +20 -0
- package/src/replica/db/0001_release_stamps.sql +6 -0
- package/src/replica/db/meta/0000_snapshot.json +129 -0
- package/src/replica/db/meta/0001_snapshot.json +167 -0
- package/src/replica/db/meta/_journal.json +20 -0
- package/src/replica/db/schema.ts +67 -0
- package/src/replica/index.ts +185 -0
- package/src/replica/meta.ts +26 -0
- package/src/replica/stamps.ts +102 -0
- package/src/replica/track.ts +190 -0
- package/src/socket-reconnect.ts +242 -0
- package/src/socket.ts +44 -0
- package/src/sql-raw.d.ts +4 -0
package/src/identity.ts
ADDED
|
@@ -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'
|
package/src/internal.ts
ADDED
|
@@ -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
|
+
}
|
package/src/mutations.ts
ADDED
|
@@ -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
|
+
}
|
package/src/raw-read.ts
ADDED
|
@@ -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
|
+
);
|