@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
|
@@ -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
|
+
}
|