@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,129 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "cc24a082-981e-465f-858f-24aab93bf4d1",
5
+ "prevId": "00000000-0000-0000-0000-000000000000",
6
+ "tables": {
7
+ "__doync_membership": {
8
+ "name": "__doync_membership",
9
+ "columns": {
10
+ "instance": {
11
+ "name": "instance",
12
+ "type": "text",
13
+ "primaryKey": false,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "level": {
18
+ "name": "level",
19
+ "type": "integer",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "tbl": {
25
+ "name": "tbl",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ },
31
+ "pk": {
32
+ "name": "pk",
33
+ "type": "text",
34
+ "primaryKey": false,
35
+ "notNull": true,
36
+ "autoincrement": false
37
+ }
38
+ },
39
+ "indexes": {
40
+ "__doync_membership_by_row": {
41
+ "name": "__doync_membership_by_row",
42
+ "columns": ["tbl", "pk"],
43
+ "isUnique": false
44
+ }
45
+ },
46
+ "foreignKeys": {},
47
+ "compositePrimaryKeys": {
48
+ "__doync_membership_instance_level_pk_pk": {
49
+ "columns": ["instance", "level", "pk"],
50
+ "name": "__doync_membership_instance_level_pk_pk"
51
+ }
52
+ },
53
+ "uniqueConstraints": {},
54
+ "checkConstraints": {}
55
+ },
56
+ "__doync_meta": {
57
+ "name": "__doync_meta",
58
+ "columns": {
59
+ "k": {
60
+ "name": "k",
61
+ "type": "text",
62
+ "primaryKey": true,
63
+ "notNull": true,
64
+ "autoincrement": false
65
+ },
66
+ "v": {
67
+ "name": "v",
68
+ "type": "blob",
69
+ "primaryKey": false,
70
+ "notNull": false,
71
+ "autoincrement": false
72
+ }
73
+ },
74
+ "indexes": {},
75
+ "foreignKeys": {},
76
+ "compositePrimaryKeys": {},
77
+ "uniqueConstraints": {},
78
+ "checkConstraints": {}
79
+ },
80
+ "__doync_pending": {
81
+ "name": "__doync_pending",
82
+ "columns": {
83
+ "mutation_id": {
84
+ "name": "mutation_id",
85
+ "type": "integer",
86
+ "primaryKey": true,
87
+ "notNull": true,
88
+ "autoincrement": false
89
+ },
90
+ "name": {
91
+ "name": "name",
92
+ "type": "text",
93
+ "primaryKey": false,
94
+ "notNull": true,
95
+ "autoincrement": false
96
+ },
97
+ "args": {
98
+ "name": "args",
99
+ "type": "text",
100
+ "primaryKey": false,
101
+ "notNull": true,
102
+ "autoincrement": false
103
+ },
104
+ "idem_key": {
105
+ "name": "idem_key",
106
+ "type": "text",
107
+ "primaryKey": false,
108
+ "notNull": false,
109
+ "autoincrement": false
110
+ }
111
+ },
112
+ "indexes": {},
113
+ "foreignKeys": {},
114
+ "compositePrimaryKeys": {},
115
+ "uniqueConstraints": {},
116
+ "checkConstraints": {}
117
+ }
118
+ },
119
+ "views": {},
120
+ "enums": {},
121
+ "_meta": {
122
+ "schemas": {},
123
+ "tables": {},
124
+ "columns": {}
125
+ },
126
+ "internal": {
127
+ "indexes": {}
128
+ }
129
+ }
@@ -0,0 +1,167 @@
1
+ {
2
+ "version": "6",
3
+ "dialect": "sqlite",
4
+ "id": "9b4ee178-f435-4f98-aebf-234ba0657b6d",
5
+ "prevId": "cc24a082-981e-465f-858f-24aab93bf4d1",
6
+ "tables": {
7
+ "__doync_membership": {
8
+ "name": "__doync_membership",
9
+ "columns": {
10
+ "instance": {
11
+ "name": "instance",
12
+ "type": "text",
13
+ "primaryKey": false,
14
+ "notNull": true,
15
+ "autoincrement": false
16
+ },
17
+ "level": {
18
+ "name": "level",
19
+ "type": "integer",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "autoincrement": false
23
+ },
24
+ "tbl": {
25
+ "name": "tbl",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "autoincrement": false
30
+ },
31
+ "pk": {
32
+ "name": "pk",
33
+ "type": "text",
34
+ "primaryKey": false,
35
+ "notNull": true,
36
+ "autoincrement": false
37
+ }
38
+ },
39
+ "indexes": {
40
+ "__doync_membership_by_row": {
41
+ "name": "__doync_membership_by_row",
42
+ "columns": ["tbl", "pk"],
43
+ "isUnique": false
44
+ }
45
+ },
46
+ "foreignKeys": {},
47
+ "compositePrimaryKeys": {
48
+ "__doync_membership_instance_level_pk_pk": {
49
+ "columns": ["instance", "level", "pk"],
50
+ "name": "__doync_membership_instance_level_pk_pk"
51
+ }
52
+ },
53
+ "uniqueConstraints": {},
54
+ "checkConstraints": {}
55
+ },
56
+ "__doync_meta": {
57
+ "name": "__doync_meta",
58
+ "columns": {
59
+ "k": {
60
+ "name": "k",
61
+ "type": "text",
62
+ "primaryKey": true,
63
+ "notNull": true,
64
+ "autoincrement": false
65
+ },
66
+ "v": {
67
+ "name": "v",
68
+ "type": "blob",
69
+ "primaryKey": false,
70
+ "notNull": false,
71
+ "autoincrement": false
72
+ }
73
+ },
74
+ "indexes": {},
75
+ "foreignKeys": {},
76
+ "compositePrimaryKeys": {},
77
+ "uniqueConstraints": {},
78
+ "checkConstraints": {}
79
+ },
80
+ "__doync_pending": {
81
+ "name": "__doync_pending",
82
+ "columns": {
83
+ "mutation_id": {
84
+ "name": "mutation_id",
85
+ "type": "integer",
86
+ "primaryKey": true,
87
+ "notNull": true,
88
+ "autoincrement": false
89
+ },
90
+ "name": {
91
+ "name": "name",
92
+ "type": "text",
93
+ "primaryKey": false,
94
+ "notNull": true,
95
+ "autoincrement": false
96
+ },
97
+ "args": {
98
+ "name": "args",
99
+ "type": "text",
100
+ "primaryKey": false,
101
+ "notNull": true,
102
+ "autoincrement": false
103
+ },
104
+ "idem_key": {
105
+ "name": "idem_key",
106
+ "type": "text",
107
+ "primaryKey": false,
108
+ "notNull": false,
109
+ "autoincrement": false
110
+ }
111
+ },
112
+ "indexes": {},
113
+ "foreignKeys": {},
114
+ "compositePrimaryKeys": {},
115
+ "uniqueConstraints": {},
116
+ "checkConstraints": {}
117
+ },
118
+ "__doync_release_stamp": {
119
+ "name": "__doync_release_stamp",
120
+ "columns": {
121
+ "instance": {
122
+ "name": "instance",
123
+ "type": "text",
124
+ "primaryKey": true,
125
+ "notNull": true,
126
+ "autoincrement": false
127
+ },
128
+ "cookie": {
129
+ "name": "cookie",
130
+ "type": "integer",
131
+ "primaryKey": false,
132
+ "notNull": true,
133
+ "autoincrement": false
134
+ },
135
+ "released_at": {
136
+ "name": "released_at",
137
+ "type": "integer",
138
+ "primaryKey": false,
139
+ "notNull": true,
140
+ "autoincrement": false
141
+ },
142
+ "ttl_ms": {
143
+ "name": "ttl_ms",
144
+ "type": "integer",
145
+ "primaryKey": false,
146
+ "notNull": true,
147
+ "autoincrement": false
148
+ }
149
+ },
150
+ "indexes": {},
151
+ "foreignKeys": {},
152
+ "compositePrimaryKeys": {},
153
+ "uniqueConstraints": {},
154
+ "checkConstraints": {}
155
+ }
156
+ },
157
+ "views": {},
158
+ "enums": {},
159
+ "_meta": {
160
+ "schemas": {},
161
+ "tables": {},
162
+ "columns": {}
163
+ },
164
+ "internal": {
165
+ "indexes": {}
166
+ }
167
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1784031358638,
9
+ "tag": "0000_replica_engine_v0",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1784450022155,
16
+ "tag": "0001_release_stamps",
17
+ "breakpoints": true
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Replica engine tables (ADR-0024) — client-role Drizzle schema, unshared with
3
+ * Origin/Mirror (similar `__doync_meta` is coincidence).
4
+ *
5
+ * - `__doync_meta` — durable clientId, next-mutid, cookie, schema version, logout
6
+ * behavior, connected clock (#223) as k/v; `v` blob for store-as-bound.
7
+ * - `__doync_pending` — optimistic queue outside the overlay savepoint.
8
+ * - `__doync_membership` — retraction refcount (instance, level, pk); EXISTS
9
+ * last-ref delete (ADR-0013); by-row index on (tbl, pk).
10
+ * - `__doync_release_stamp` — parked releases for heldAt (#225) and GC (#226);
11
+ * only while released; wiped with memberships on resync/forget.
12
+ *
13
+ * Consumer tables arrive via bundled migrations + patches. Ledger
14
+ * `__doync_engine` is created unversioned by `runReplicaEngineTrack`.
15
+ */
16
+ import {
17
+ blob,
18
+ index,
19
+ integer,
20
+ primaryKey,
21
+ sqliteTable,
22
+ text,
23
+ } from 'drizzle-orm/sqlite-core'
24
+
25
+ /** Client durable kv — see module docblock. without rowid */
26
+ export const doyncMeta = sqliteTable('__doync_meta', {
27
+ k: text('k').primaryKey(),
28
+ // blob affinity: store-as-bound for mixed number/string values (ADR-0024)
29
+ v: blob('v'),
30
+ })
31
+
32
+ /** Durable optimistic mutation queue. (rowid table — INTEGER PRIMARY KEY) */
33
+ export const doyncPending = sqliteTable('__doync_pending', {
34
+ mutationId: integer('mutation_id').primaryKey(),
35
+ name: text('name').notNull(),
36
+ args: text('args').notNull(),
37
+ idemKey: text('idem_key'),
38
+ })
39
+
40
+ /** Client-side membership refcount. without rowid */
41
+ export const doyncMembership = sqliteTable(
42
+ '__doync_membership',
43
+ {
44
+ instance: text('instance').notNull(),
45
+ level: integer('level').notNull(),
46
+ tbl: text('tbl').notNull(),
47
+ pk: text('pk').notNull(),
48
+ },
49
+ (t) => [
50
+ primaryKey({ columns: [t.instance, t.level, t.pk] }),
51
+ index('__doync_membership_by_row').on(t.tbl, t.pk),
52
+ ],
53
+ )
54
+
55
+ /**
56
+ * Durable release stamp per parked Query instance (ADR-0014 client half /
57
+ * closeio/doync#224). `cookie` is the Client's scalar cookie at last-release
58
+ * (the future `heldAt` claim); `released_at` is that moment in connected-clock
59
+ * units; `ttl_ms` is the resolved grace (declared, else the internal 30m
60
+ * default matching the server's DEFAULT_QUERY_TTL_MS). without rowid
61
+ */
62
+ export const doyncReleaseStamp = sqliteTable('__doync_release_stamp', {
63
+ instance: text('instance').primaryKey(),
64
+ cookie: integer('cookie').notNull(),
65
+ releasedAt: integer('released_at').notNull(),
66
+ ttlMs: integer('ttl_ms').notNull(),
67
+ })
@@ -0,0 +1,185 @@
1
+ import type { DoyncSchema, SqlValue, TableSchema } from '@doync/core'
2
+
3
+ import {
4
+ decodeImageValue,
5
+ decodePkValue,
6
+ declaredSchemaVersion,
7
+ isInternalTable,
8
+ isSqlIdentifier,
9
+ tablePrimaryKey,
10
+ } from '@doync/core/internal'
11
+
12
+ import type { LocalDb } from '../port'
13
+
14
+ import { bundledSchemaDdl } from '../migrate'
15
+ import { readMeta, writeMeta } from './meta'
16
+ import { runReplicaEngineTrack } from './track'
17
+
18
+ export { readMeta, writeMeta } from './meta'
19
+ export {
20
+ clearReleaseStamps,
21
+ DEFAULT_RELEASE_TTL_MS,
22
+ deleteReleaseStamp,
23
+ listReleaseStamps,
24
+ readReleaseStamp,
25
+ resolveReleaseTtlMs,
26
+ upsertReleaseStamp,
27
+ type ReleaseStamp,
28
+ } from './stamps'
29
+ export {
30
+ REPLICA_ENGINE_TRACK,
31
+ runReplicaEngineTrack,
32
+ type EngineTrackResult,
33
+ type EngineTrackStep,
34
+ } from './track'
35
+
36
+ /**
37
+ * The replica-side helpers the engine uses to apply patches to the consumer's
38
+ * local tables (ADR-0013/0019). The client holds a REAL partial-Mirror replica:
39
+ * whole rows land in the consumer's own tables so Local reads and subscription
40
+ * snapshots run ordinary SQL over them. These mirror the server's `sql.ts`
41
+ * pk/image plumbing, kept minimal and client-local (the server module is not a
42
+ * shared export).
43
+ */
44
+
45
+ /** Quote a validated identifier for interpolation (never user text). */
46
+ export function quoteIdent(name: string): string {
47
+ if (!isSqlIdentifier(name)) {
48
+ throw new Error(
49
+ `doync: refusing to quote non-identifier ${JSON.stringify(name)}`,
50
+ )
51
+ }
52
+ return `"${name}"`
53
+ }
54
+
55
+ /**
56
+ * Parse a canonical serialized primary key (ADR-0003: a JSON array of the pk
57
+ * column values in key order, BLOB parts as hex-tagged objects) into bound
58
+ * values, in the table's key order. The `pk` on a wire `Patch` is exactly this
59
+ * form.
60
+ */
61
+ export function parsePk(pk: string, table: TableSchema): SqlValue[] {
62
+ const parts: unknown = JSON.parse(pk)
63
+ const key = tablePrimaryKey(table)
64
+ if (!Array.isArray(parts) || parts.length !== key.length) {
65
+ throw new Error(
66
+ `doync: malformed pk ${pk} for table ${table.name} (expected ${key.length} key part(s))`,
67
+ )
68
+ }
69
+ return parts.map((part) => decodePkValue(part))
70
+ }
71
+
72
+ /** `pk_a = ? AND pk_b = ?` over a table's primary-key columns, in key order. */
73
+ export function pkWhereClause(table: TableSchema): string {
74
+ return tablePrimaryKey(table)
75
+ .map((column) => `${quoteIdent(column)} = ?`)
76
+ .join(' AND ')
77
+ }
78
+
79
+ /**
80
+ * Decode a wire `PutPatch.image` (a codec-tagged row object, ADR-0010) into a
81
+ * parallel columns/values pair for an INSERT — the same one decode path the
82
+ * server's apply uses. A tagged blob becomes an `ArrayBuffer`; a scalar passes
83
+ * through; column names are validated as SQL identifiers before interpolation.
84
+ */
85
+ export function decodePatchImage(
86
+ table: TableSchema,
87
+ image: Record<string, unknown>,
88
+ ): { columns: string[]; values: SqlValue[] } {
89
+ const columns = Object.keys(image)
90
+ for (const column of columns) {
91
+ if (!isSqlIdentifier(column)) {
92
+ throw new Error(
93
+ `doync: patch image for table ${table.name} has an invalid column name ${JSON.stringify(column)}`,
94
+ )
95
+ }
96
+ }
97
+ return {
98
+ columns,
99
+ values: columns.map((column) => decodeImageValue(image[column])),
100
+ }
101
+ }
102
+
103
+ /** Look a synced table up by name; throw if the schema does not declare it. */
104
+ export function requireTable(schema: DoyncSchema, name: string): TableSchema {
105
+ const table = schema.tables.find((t) => t.name === name)
106
+ if (table === undefined) {
107
+ throw new Error(
108
+ `doync: no synced table named ${JSON.stringify(name)} in the client schema`,
109
+ )
110
+ }
111
+ return table
112
+ }
113
+
114
+ /**
115
+ * Apply the Replica engine track via `__doync_engine` ledger (ADR-0024 / #159).
116
+ * LocalDb twin of server `runEngineTrack`. Returns whether rollback tripwire
117
+ * fired (caller finishes consumer wipe-and-resync). Named `createEngineTables`
118
+ * for stable call sites.
119
+ */
120
+ export function createEngineTables(db: LocalDb): {
121
+ rolledBack: boolean
122
+ } {
123
+ return runReplicaEngineTrack(db)
124
+ }
125
+
126
+ /**
127
+ * Boot replica shape via DDL-only bundled track (ADR-0020). Fresh client is a
128
+ * partial Mirror of the same migrations (triggers skipped, FKs off ADR-0009;
129
+ * backfill skipped ADR-0006).
130
+ *
131
+ * - Fresh (no schema_version): full bundle track.
132
+ * - Persisted: leave recorded shape; new migrations arrive mid-session as
133
+ * `schema` directives ({@link applyBundledMigrations}), not at boot.
134
+ */
135
+ export function replayMigrations(db: LocalDb, schema: DoyncSchema): void {
136
+ if (readMeta(db, 'schema_version') !== null) return
137
+ applyBundledMigrations(db, schema, 0, declaredSchemaVersion(schema))
138
+ }
139
+
140
+ /**
141
+ * Apply the bundled track's non-trigger DDL for the migrations in
142
+ * `(fromVersion, toVersion]` to the replica and record `toVersion` as the
143
+ * applied `schema_version`. The single primitive behind both fresh boot replay
144
+ * ({@link replayMigrations} with `fromVersion = 0`) and a mid-session catch-up
145
+ * (`fromVersion =` the currently applied version). The DDL text is the client's
146
+ * OWN bundle (never wire-carried — ADR-0020); a `CREATE TABLE` from a fresh
147
+ * base and an `ALTER TABLE … ADD COLUMN` catch-up both keep any rows already
148
+ * present (SQLite ADD COLUMN preserves rows). Throws if a statement fails — the
149
+ * caller turns a failed local migration into wipe-and-resync (ADR-0020).
150
+ */
151
+ export function applyBundledMigrations(
152
+ db: LocalDb,
153
+ schema: DoyncSchema,
154
+ fromVersion: number,
155
+ toVersion: number,
156
+ ): void {
157
+ for (const ddl of bundledSchemaDdl(schema, fromVersion, toVersion)) {
158
+ db.execBatch(ddl)
159
+ }
160
+ writeMeta(db, 'schema_version', String(toVersion))
161
+ }
162
+
163
+ /**
164
+ * Drop every consumer (synced) table (ADR-0020's wipe fallback): the first step
165
+ * of wipe-replica-and-resync, run with foreign keys already off so no cascade
166
+ * fires and drop order is irrelevant (ADR-0009). `__doync_`-prefixed engine
167
+ * tables — the pending queue, clientId, and `__doync_engine` ledger that
168
+ * SURVIVE the wipe — are left untouched.
169
+ */
170
+ export function dropConsumerTables(db: LocalDb, schema: DoyncSchema): void {
171
+ for (const table of schema.tables) {
172
+ db.exec(`DROP TABLE IF EXISTS ${quoteIdent(table.name)}`)
173
+ }
174
+ }
175
+
176
+ /** Assert the schema declares no `__doync_`-prefixed synced table (ADR-0007). */
177
+ export function assertNoReservedTables(schema: DoyncSchema): void {
178
+ for (const table of schema.tables) {
179
+ if (isInternalTable(table.name)) {
180
+ throw new Error(
181
+ `doync: synced table ${table.name} uses the reserved __doync_ prefix`,
182
+ )
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,26 @@
1
+ import type { SqlValue } from '@doync/core'
2
+
3
+ import type { LocalDb } from '../port'
4
+
5
+ /**
6
+ * Read one `__doync_meta` value, or `null` if unset. Columns are `k`/`v`
7
+ * (ADR-0024).
8
+ */
9
+ export function readMeta(db: LocalDb, key: string): string | null {
10
+ const row = db.exec<{ v: SqlValue }>(
11
+ `SELECT v FROM __doync_meta WHERE k = ?`,
12
+ key,
13
+ )[0]
14
+ if (row === undefined || row.v === null) return null
15
+ return String(row.v)
16
+ }
17
+
18
+ /** Upsert one `__doync_meta` value. Columns are `k`/`v` (ADR-0024). */
19
+ export function writeMeta(db: LocalDb, key: string, value: string): void {
20
+ db.exec(
21
+ `INSERT INTO __doync_meta (k, v) VALUES (?, ?)
22
+ ON CONFLICT (k) DO UPDATE SET v = excluded.v`,
23
+ key,
24
+ value,
25
+ )
26
+ }
@@ -0,0 +1,102 @@
1
+ import type { LocalDb } from '../port'
2
+
3
+ /**
4
+ * Durable release stamp for one parked Query instance (ADR-0014 client half /
5
+ * closeio/doync#224). Shared infrastructure for `heldAt` (#225) and membership
6
+ * GC (#226) — package-internal, no public consumer surface.
7
+ *
8
+ * Lifecycle: written at last-release; deleted on re-desire and on an ADR-0028
9
+ * warmth-break; wiped with memberships on resync/forget (a stamp must never
10
+ * outlive the memberships it vouches for).
11
+ */
12
+ export type ReleaseStamp = {
13
+ readonly instance: string
14
+ /** Client scalar cookie at last-release — the future `heldAt` claim. */
15
+ readonly cookie: number
16
+ /** Release moment in connected-clock units (ms). */
17
+ readonly releasedAt: number
18
+ /**
19
+ * Resolved grace in ms — the Subscription's declared ttl, or the internal
20
+ * default (mirrors the server's DEFAULT_QUERY_TTL_MS; no public knob).
21
+ */
22
+ readonly ttlMs: number
23
+ }
24
+
25
+ /**
26
+ * Fallback when a Subscription declares no ttl — mirrors server
27
+ * DEFAULT_QUERY_TTL_MS (30m). Not a public knob; duplicated so client does not
28
+ * depend on server.
29
+ */
30
+ export const DEFAULT_RELEASE_TTL_MS: number = 30 * 60 * 1000
31
+
32
+ /** Resolve the stamp's recorded ttl: declared value, else the internal default. */
33
+ export function resolveReleaseTtlMs(declared: number | undefined): number {
34
+ return declared === undefined ? DEFAULT_RELEASE_TTL_MS : declared
35
+ }
36
+
37
+ /** Upsert one stamp (at most one per instance; a re-release overwrites). */
38
+ export function upsertReleaseStamp(db: LocalDb, stamp: ReleaseStamp): void {
39
+ db.exec(
40
+ `INSERT INTO __doync_release_stamp (instance, cookie, released_at, ttl_ms)
41
+ VALUES (?, ?, ?, ?)
42
+ ON CONFLICT (instance) DO UPDATE SET
43
+ cookie = excluded.cookie,
44
+ released_at = excluded.released_at,
45
+ ttl_ms = excluded.ttl_ms`,
46
+ stamp.instance,
47
+ stamp.cookie,
48
+ stamp.releasedAt,
49
+ stamp.ttlMs,
50
+ )
51
+ }
52
+
53
+ /** Delete the stamp for one instance (no-op when absent). */
54
+ export function deleteReleaseStamp(db: LocalDb, instance: string): void {
55
+ db.exec(`DELETE FROM __doync_release_stamp WHERE instance = ?`, instance)
56
+ }
57
+
58
+ /** Drop every stamp — paired with membership wipe on resync/forget. */
59
+ export function clearReleaseStamps(db: LocalDb): void {
60
+ db.exec(`DELETE FROM __doync_release_stamp`)
61
+ }
62
+
63
+ type StampRow = {
64
+ instance: string | number
65
+ cookie: number | string
66
+ released_at: number | string
67
+ ttl_ms: number | string
68
+ }
69
+
70
+ function rowToStamp(row: StampRow): ReleaseStamp {
71
+ return {
72
+ instance: String(row.instance),
73
+ cookie: Number(row.cookie),
74
+ releasedAt: Number(row.released_at),
75
+ ttlMs: Number(row.ttl_ms),
76
+ }
77
+ }
78
+
79
+ /** Read one stamp by instance identity, or `null` when none. */
80
+ export function readReleaseStamp(
81
+ db: LocalDb,
82
+ instance: string,
83
+ ): ReleaseStamp | null {
84
+ const row = db.exec<StampRow>(
85
+ `SELECT instance, cookie, released_at, ttl_ms
86
+ FROM __doync_release_stamp WHERE instance = ?`,
87
+ instance,
88
+ )[0]
89
+ return row === undefined ? null : rowToStamp(row)
90
+ }
91
+
92
+ /**
93
+ * Enumerate every parked stamp (closeio/doync#226 hygiene GC walks this). Order
94
+ * is unspecified.
95
+ */
96
+ export function listReleaseStamps(db: LocalDb): ReleaseStamp[] {
97
+ return db
98
+ .exec<StampRow>(
99
+ `SELECT instance, cookie, released_at, ttl_ms FROM __doync_release_stamp`,
100
+ )
101
+ .map(rowToStamp)
102
+ }