orez-sync-cf-host 0.4.52
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/README.md +143 -0
- package/package.json +30 -0
- package/src/config.ts +47 -0
- package/src/generated/package.json +16 -0
- package/src/generated/sync_wasm.d.ts +123 -0
- package/src/generated/sync_wasm.js +824 -0
- package/src/generated/sync_wasm_bg.wasm +0 -0
- package/src/generated/sync_wasm_bg.wasm.d.ts +2 -0
- package/src/harness-config.ts +384 -0
- package/src/harness-worker.ts +13 -0
- package/src/host.ts +1264 -0
- package/src/index.ts +22 -0
- package/src/ingest-harness-worker.ts +119 -0
- package/src/platform-probe-worker.ts +304 -0
- package/src/sql-storage-adapter.ts +168 -0
- package/src/types.ts +153 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { createSyncDurableObject, createSyncWorker } from './host.js'
|
|
2
|
+
export { MutationApplicationError, registerMutators } from './types.js'
|
|
3
|
+
export type {
|
|
4
|
+
DeferredEffect,
|
|
5
|
+
JsonPrimitive,
|
|
6
|
+
JsonValue,
|
|
7
|
+
MutatorContext,
|
|
8
|
+
MutatorRegistry,
|
|
9
|
+
MutatorSql,
|
|
10
|
+
NormalizedClaims,
|
|
11
|
+
PullCaps,
|
|
12
|
+
QueryResolver,
|
|
13
|
+
RegisteredMutator,
|
|
14
|
+
SyncHostConfig,
|
|
15
|
+
SyncHostEnv,
|
|
16
|
+
ServiceBinding,
|
|
17
|
+
SyncSql,
|
|
18
|
+
UpstreamConfig,
|
|
19
|
+
VisibilityConfig,
|
|
20
|
+
VisibilityFilter,
|
|
21
|
+
ZeroSchemaConfig,
|
|
22
|
+
} from './types.js'
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { WorkerEntrypoint } from 'cloudflare:workers'
|
|
2
|
+
|
|
3
|
+
import { ZeroDO } from '../../../src/cf-do/worker.js'
|
|
4
|
+
import { createSyncDurableObject, createSyncWorker } from './index.js'
|
|
5
|
+
|
|
6
|
+
import type { SyncHostConfig, SyncHostEnv, ZeroSchemaConfig } from './index.js'
|
|
7
|
+
|
|
8
|
+
const schema = {
|
|
9
|
+
tables: {
|
|
10
|
+
item: {
|
|
11
|
+
columns: {
|
|
12
|
+
id: { type: 'string' },
|
|
13
|
+
label: { type: 'string' },
|
|
14
|
+
rank: { type: 'number' },
|
|
15
|
+
done: { type: 'boolean' },
|
|
16
|
+
meta: { type: 'json' },
|
|
17
|
+
},
|
|
18
|
+
primaryKey: ['id'],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
} as const satisfies ZeroSchemaConfig
|
|
22
|
+
|
|
23
|
+
type Fetcher = { fetch(input: string | Request, init?: RequestInit): Promise<Response> }
|
|
24
|
+
interface Env extends SyncHostEnv {
|
|
25
|
+
DATA: Fetcher
|
|
26
|
+
APP: Fetcher
|
|
27
|
+
UPSTREAM_DO: DurableObjectNamespace
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const config: SyncHostConfig<Env> = {
|
|
31
|
+
hostVersion: 'upstream-ingest-harness',
|
|
32
|
+
schema,
|
|
33
|
+
mutateUrl: '/api/zero/push',
|
|
34
|
+
mutateBinding: 'APP',
|
|
35
|
+
upstream: {
|
|
36
|
+
binding: 'DATA',
|
|
37
|
+
namespacePath: (namespace) => `/${namespace}`,
|
|
38
|
+
changeLimit: 2,
|
|
39
|
+
intervalMs: 1_000,
|
|
40
|
+
},
|
|
41
|
+
initialize(sql) {
|
|
42
|
+
sql.exec(
|
|
43
|
+
'CREATE TABLE IF NOT EXISTS item (id TEXT PRIMARY KEY, label TEXT NOT NULL, rank REAL NOT NULL, done INTEGER NOT NULL, meta TEXT)'
|
|
44
|
+
)
|
|
45
|
+
},
|
|
46
|
+
authenticate(request) {
|
|
47
|
+
const userID = request.headers.get('authorization')?.match(/^Bearer token-(.+)$/)?.[1]
|
|
48
|
+
return userID ? { userID } : null
|
|
49
|
+
},
|
|
50
|
+
namespace(request) {
|
|
51
|
+
return new URL(request.url).pathname.split('/')[1] || null
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const SyncDurableObject = createSyncDurableObject(config)
|
|
56
|
+
export { ZeroDO }
|
|
57
|
+
|
|
58
|
+
async function upstreamFetch(request: Request, env: Env): Promise<Response> {
|
|
59
|
+
const url = new URL(request.url)
|
|
60
|
+
const [, namespace, ...rest] = url.pathname.split('/')
|
|
61
|
+
if (!namespace) return new Response('namespace required', { status: 400 })
|
|
62
|
+
const stub = env.UPSTREAM_DO.get(env.UPSTREAM_DO.idFromName(namespace))
|
|
63
|
+
const exec = async (sql: string, params: unknown[] = []) => {
|
|
64
|
+
const response = await stub.fetch('https://upstream.invalid/exec', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ sql, params }),
|
|
68
|
+
})
|
|
69
|
+
if (!response.ok) throw new Error(`upstream init failed: ${await response.text()}`)
|
|
70
|
+
}
|
|
71
|
+
await exec(
|
|
72
|
+
'CREATE TABLE IF NOT EXISTS item (id TEXT PRIMARY KEY, label TEXT NOT NULL, rank REAL NOT NULL, done INTEGER NOT NULL, meta TEXT)'
|
|
73
|
+
)
|
|
74
|
+
await exec(
|
|
75
|
+
'CREATE TABLE IF NOT EXISTS _zero_schema_tables (name TEXT PRIMARY KEY, schema_json TEXT NOT NULL)'
|
|
76
|
+
)
|
|
77
|
+
await exec(
|
|
78
|
+
'INSERT OR IGNORE INTO _zero_schema_tables (name, schema_json) VALUES (?, ?)',
|
|
79
|
+
['item', JSON.stringify(schema.tables.item)]
|
|
80
|
+
)
|
|
81
|
+
url.pathname = `/${rest.join('/')}`
|
|
82
|
+
return stub.fetch(new Request(url, request))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Self service-binding target backed by the real ZeroSqlDO. */
|
|
86
|
+
export class DataService extends WorkerEntrypoint<Env> {
|
|
87
|
+
fetch(request: Request): Promise<Response> {
|
|
88
|
+
const pathname = new URL(request.url).pathname
|
|
89
|
+
if (!pathname.endsWith('/changes') && !pathname.endsWith('/snapshot')) {
|
|
90
|
+
return Promise.resolve(
|
|
91
|
+
new Response('DATA route rejected non-feed request', { status: 418 })
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
return upstreamFetch(request, this.env)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class AppService extends WorkerEntrypoint<Env> {
|
|
99
|
+
fetch(request: Request): Promise<Response> {
|
|
100
|
+
if (!new URL(request.url).pathname.endsWith('/api/zero/push')) {
|
|
101
|
+
return Promise.resolve(
|
|
102
|
+
new Response('APP route rejected non-push request', { status: 418 })
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
return upstreamFetch(request, this.env)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const syncWorker = createSyncWorker(config)
|
|
110
|
+
export default {
|
|
111
|
+
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
112
|
+
const url = new URL(request.url)
|
|
113
|
+
if (url.pathname.startsWith('/upstream/')) {
|
|
114
|
+
url.pathname = url.pathname.slice('/upstream'.length)
|
|
115
|
+
return upstreamFetch(new Request(url, request), env)
|
|
116
|
+
}
|
|
117
|
+
return syncWorker.fetch!(request as never, env, ctx) as Promise<Response>
|
|
118
|
+
},
|
|
119
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
initSync,
|
|
5
|
+
init_probe_schema,
|
|
6
|
+
pull_snapshot,
|
|
7
|
+
push_finalize,
|
|
8
|
+
push_preflight,
|
|
9
|
+
rust_panic_after_writes,
|
|
10
|
+
value_round_trip,
|
|
11
|
+
} from './generated/sync_wasm.js'
|
|
12
|
+
import wasmModule from './generated/sync_wasm_bg.wasm'
|
|
13
|
+
import { SqlStorageSyncDb } from './sql-storage-adapter.js'
|
|
14
|
+
|
|
15
|
+
initSync({ module: wasmModule })
|
|
16
|
+
|
|
17
|
+
interface Env {
|
|
18
|
+
PROBE_DO: DurableObjectNamespace<ProbeDurableObject>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type DeferredEffect = { mutationID: string; kind: string }
|
|
22
|
+
type MutatorName = 'read-then-write' | 'multi-table' | 'application-error'
|
|
23
|
+
|
|
24
|
+
// Deterministic local analogue of normal DO idle eviction, matching the
|
|
25
|
+
// harness/cf probe: discard all in-memory state after an idle gap while SQL
|
|
26
|
+
// storage remains intact. This keeps the test sub-second instead of waiting
|
|
27
|
+
// for workerd/platform eviction heuristics.
|
|
28
|
+
const IDLE_TEARDOWN_MS = 250
|
|
29
|
+
|
|
30
|
+
const json = (value: unknown, status = 200) =>
|
|
31
|
+
Response.json(value, {
|
|
32
|
+
status,
|
|
33
|
+
headers: { 'cache-control': 'no-store' },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export class ProbeDurableObject extends DurableObject<Env> {
|
|
37
|
+
readonly #db: SqlStorageSyncDb
|
|
38
|
+
#bootID = crypto.randomUUID()
|
|
39
|
+
#lastRequestAt = 0
|
|
40
|
+
#reinstantiations = 0
|
|
41
|
+
#effects: Array<DeferredEffect & { observedCommitted: boolean }> = []
|
|
42
|
+
|
|
43
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
44
|
+
super(ctx, env)
|
|
45
|
+
this.#db = new SqlStorageSyncDb(ctx.storage.sql)
|
|
46
|
+
ctx.storage.transactionSync(() => init_probe_schema(this.#db))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#maybeReinstantiate(now: number): void {
|
|
50
|
+
if (this.#lastRequestAt > 0 && now - this.#lastRequestAt >= IDLE_TEARDOWN_MS) {
|
|
51
|
+
this.#bootID = crypto.randomUUID()
|
|
52
|
+
this.#effects = []
|
|
53
|
+
this.#reinstantiations++
|
|
54
|
+
}
|
|
55
|
+
this.#lastRequestAt = now
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#state(): Record<string, unknown> {
|
|
59
|
+
const row = this.ctx.storage.sql
|
|
60
|
+
.exec(
|
|
61
|
+
`SELECT
|
|
62
|
+
CAST((SELECT lmid FROM probe_state WHERE singleton = 1) AS TEXT) AS lmid,
|
|
63
|
+
(SELECT balance FROM accounts WHERE id = 'primary') AS balance,
|
|
64
|
+
(SELECT COUNT(*) FROM ledger) AS ledgerCount,
|
|
65
|
+
(SELECT COUNT(*) FROM outbox) AS outboxCount,
|
|
66
|
+
(SELECT COUNT(*) FROM mutation_log) AS mutationCount,
|
|
67
|
+
(SELECT COUNT(*) FROM value_probe) AS valueCount`
|
|
68
|
+
)
|
|
69
|
+
.one()
|
|
70
|
+
return {
|
|
71
|
+
...row,
|
|
72
|
+
sideEffectCount: this.#effects.length,
|
|
73
|
+
sideEffects: this.#effects,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async #mutate(name: MutatorName, deferred: DeferredEffect[]): Promise<void> {
|
|
78
|
+
if (name === 'read-then-write') {
|
|
79
|
+
const { balance } = this.ctx.storage.sql
|
|
80
|
+
.exec("SELECT balance FROM accounts WHERE id = 'primary'")
|
|
81
|
+
.one() as { balance: number }
|
|
82
|
+
await scheduler.wait(1)
|
|
83
|
+
this.ctx.storage.sql.exec(
|
|
84
|
+
"UPDATE accounts SET balance = ? WHERE id = 'primary'",
|
|
85
|
+
balance + 10
|
|
86
|
+
)
|
|
87
|
+
this.ctx.storage.sql.exec(
|
|
88
|
+
'INSERT INTO ledger (account_id, amount, note) VALUES (?, ?, ?)',
|
|
89
|
+
'primary',
|
|
90
|
+
10,
|
|
91
|
+
'read-then-write'
|
|
92
|
+
)
|
|
93
|
+
deferred.push({ mutationID: '', kind: 'balance-notification' })
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (name === 'multi-table') {
|
|
98
|
+
this.ctx.storage.sql.exec(
|
|
99
|
+
"UPDATE accounts SET balance = balance - 5 WHERE id = 'primary'"
|
|
100
|
+
)
|
|
101
|
+
await scheduler.wait(1)
|
|
102
|
+
this.ctx.storage.sql.exec(
|
|
103
|
+
'INSERT INTO ledger (account_id, amount, note) VALUES (?, ?, ?)',
|
|
104
|
+
'primary',
|
|
105
|
+
-5,
|
|
106
|
+
'multi-table'
|
|
107
|
+
)
|
|
108
|
+
this.ctx.storage.sql.exec(
|
|
109
|
+
'INSERT INTO outbox (topic, payload) VALUES (?, ?)',
|
|
110
|
+
'account.changed',
|
|
111
|
+
JSON.stringify({ id: 'primary', delta: -5 })
|
|
112
|
+
)
|
|
113
|
+
deferred.push({ mutationID: '', kind: 'outbox-notification' })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const { balance } = this.ctx.storage.sql
|
|
118
|
+
.exec("SELECT balance FROM accounts WHERE id = 'primary'")
|
|
119
|
+
.one() as { balance: number }
|
|
120
|
+
this.ctx.storage.sql.exec(
|
|
121
|
+
"UPDATE accounts SET balance = ? WHERE id = 'primary'",
|
|
122
|
+
balance + 777
|
|
123
|
+
)
|
|
124
|
+
await scheduler.wait(1)
|
|
125
|
+
this.ctx.storage.sql.exec(
|
|
126
|
+
'INSERT INTO ledger (account_id, amount, note) VALUES (?, ?, ?)',
|
|
127
|
+
'primary',
|
|
128
|
+
777,
|
|
129
|
+
'application-error'
|
|
130
|
+
)
|
|
131
|
+
deferred.push({ mutationID: '', kind: 'must-not-run' })
|
|
132
|
+
throw new Error('intentional application mutator error')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async #push(name: MutatorName, mutationID: string): Promise<Response> {
|
|
136
|
+
this.#db.resetStats()
|
|
137
|
+
const deferred: DeferredEffect[] = []
|
|
138
|
+
const started = performance.now()
|
|
139
|
+
let lmid: string | undefined
|
|
140
|
+
let wasmMs = 0
|
|
141
|
+
try {
|
|
142
|
+
await this.ctx.storage.transaction(async () => {
|
|
143
|
+
let wasmStarted = performance.now()
|
|
144
|
+
const expected = push_preflight(this.#db, mutationID)
|
|
145
|
+
wasmMs += performance.now() - wasmStarted
|
|
146
|
+
|
|
147
|
+
// This await is deliberately inside the storage transaction and before
|
|
148
|
+
// the remaining SQL operations. M0 exists to prove this contract.
|
|
149
|
+
await scheduler.wait(1)
|
|
150
|
+
await this.#mutate(name, deferred)
|
|
151
|
+
|
|
152
|
+
wasmStarted = performance.now()
|
|
153
|
+
lmid = push_finalize(this.#db, mutationID, expected)
|
|
154
|
+
wasmMs += performance.now() - wasmStarted
|
|
155
|
+
})
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return json(
|
|
158
|
+
{
|
|
159
|
+
ok: false,
|
|
160
|
+
error: String(error),
|
|
161
|
+
awaitedInsideTransaction: true,
|
|
162
|
+
state: this.#state(),
|
|
163
|
+
effectsDeferredButNotRun: deferred.length,
|
|
164
|
+
},
|
|
165
|
+
409
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// External effects are not even inspected until the transaction promise
|
|
170
|
+
// resolves. Each effect independently verifies its mutation is durable.
|
|
171
|
+
for (const effect of deferred) {
|
|
172
|
+
effect.mutationID = mutationID
|
|
173
|
+
const committed =
|
|
174
|
+
this.ctx.storage.sql
|
|
175
|
+
.exec(
|
|
176
|
+
'SELECT 1 AS committed FROM mutation_log WHERE mutation_id = ?',
|
|
177
|
+
mutationID
|
|
178
|
+
)
|
|
179
|
+
.toArray().length === 1
|
|
180
|
+
this.#effects.push({ ...effect, observedCommitted: committed })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return json({
|
|
184
|
+
ok: true,
|
|
185
|
+
lmid,
|
|
186
|
+
awaitedInsideTransaction: true,
|
|
187
|
+
state: this.#state(),
|
|
188
|
+
timing: {
|
|
189
|
+
elapsedMs: performance.now() - started,
|
|
190
|
+
wasmMs,
|
|
191
|
+
...this.#db.stats,
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async fetch(request: Request): Promise<Response> {
|
|
197
|
+
this.#maybeReinstantiate(Date.now())
|
|
198
|
+
const url = new URL(request.url)
|
|
199
|
+
const [, , ...routeParts] = url.pathname.split('/')
|
|
200
|
+
const route = `/${routeParts.join('/')}`
|
|
201
|
+
|
|
202
|
+
if (route === '/status') {
|
|
203
|
+
return json({
|
|
204
|
+
bootID: this.#bootID,
|
|
205
|
+
idleTeardownMs: IDLE_TEARDOWN_MS,
|
|
206
|
+
reinstantiations: this.#reinstantiations,
|
|
207
|
+
state: this.#state(),
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (route === '/pull') {
|
|
212
|
+
this.#db.resetStats()
|
|
213
|
+
const started = performance.now()
|
|
214
|
+
const snapshot = this.ctx.storage.transactionSync(() => pull_snapshot(this.#db))
|
|
215
|
+
return json({
|
|
216
|
+
snapshot,
|
|
217
|
+
transaction: 'transactionSync',
|
|
218
|
+
timing: { elapsedMs: performance.now() - started, ...this.#db.stats },
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (route.startsWith('/push/')) {
|
|
223
|
+
const name = route.slice('/push/'.length) as MutatorName
|
|
224
|
+
if (!['read-then-write', 'multi-table', 'application-error'].includes(name)) {
|
|
225
|
+
return json({ error: 'unknown mutator' }, 404)
|
|
226
|
+
}
|
|
227
|
+
const body = (await request.json()) as { mutationID: string }
|
|
228
|
+
return this.#push(name, body.mutationID)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (route === '/js-exception') {
|
|
232
|
+
const before = this.#state()
|
|
233
|
+
try {
|
|
234
|
+
await this.ctx.storage.transaction(async () => {
|
|
235
|
+
const expected = push_preflight(this.#db, 'js-exception')
|
|
236
|
+
this.ctx.storage.sql.exec(
|
|
237
|
+
"UPDATE accounts SET balance = balance + 123 WHERE id = 'primary'"
|
|
238
|
+
)
|
|
239
|
+
await scheduler.wait(1)
|
|
240
|
+
this.ctx.storage.sql.exec(
|
|
241
|
+
'INSERT INTO outbox (topic, payload) VALUES (?, ?)',
|
|
242
|
+
'must.rollback',
|
|
243
|
+
'{}'
|
|
244
|
+
)
|
|
245
|
+
push_finalize(this.#db, 'js-exception', expected)
|
|
246
|
+
throw new Error('intentional JS exception after finalization')
|
|
247
|
+
})
|
|
248
|
+
} catch (error) {
|
|
249
|
+
return json(
|
|
250
|
+
{ ok: false, error: String(error), before, after: this.#state() },
|
|
251
|
+
409
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
return json({ error: 'JS exception did not escape transaction' }, 500)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (route === '/rust-panic') {
|
|
258
|
+
const before = this.#state()
|
|
259
|
+
try {
|
|
260
|
+
this.ctx.storage.transactionSync(() => rust_panic_after_writes(this.#db))
|
|
261
|
+
} catch (error) {
|
|
262
|
+
return json(
|
|
263
|
+
{ ok: false, error: String(error), before, after: this.#state() },
|
|
264
|
+
409
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
return json({ error: 'Rust panic did not escape transaction' }, 500)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (route === '/values') {
|
|
271
|
+
const input = await request.json()
|
|
272
|
+
const output = this.ctx.storage.transactionSync(() =>
|
|
273
|
+
value_round_trip(this.#db, input)
|
|
274
|
+
)
|
|
275
|
+
return json(output)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (route === '/adapter-guard') {
|
|
279
|
+
const errors: string[] = []
|
|
280
|
+
for (const [sql, params] of [
|
|
281
|
+
['BEGIN TRANSACTION', []],
|
|
282
|
+
['SELECT ?1', [{ kind: 'integer', value: '1' }]],
|
|
283
|
+
] as const) {
|
|
284
|
+
try {
|
|
285
|
+
this.#db.exec(sql, [...params] as never)
|
|
286
|
+
} catch (error) {
|
|
287
|
+
errors.push(String(error))
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return json({ errors })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return json({ error: 'not found', route }, 404)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export default {
|
|
298
|
+
fetch(request: Request, env: Env): Promise<Response> {
|
|
299
|
+
const url = new URL(request.url)
|
|
300
|
+
const [, namespace] = url.pathname.split('/')
|
|
301
|
+
if (!namespace) return Promise.resolve(new Response('orez rust sync M0 probe'))
|
|
302
|
+
return env.PROBE_DO.get(env.PROBE_DO.idFromName(namespace)).fetch(request)
|
|
303
|
+
},
|
|
304
|
+
} satisfies ExportedHandler<Env>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { MutatorSql, SyncSql } from './types.js'
|
|
2
|
+
|
|
3
|
+
type WireValue =
|
|
4
|
+
| { kind: 'null' }
|
|
5
|
+
| { kind: 'integer'; value: string }
|
|
6
|
+
| { kind: 'real'; value: number }
|
|
7
|
+
| { kind: 'text'; value: string }
|
|
8
|
+
| { kind: 'blob'; value: number[] }
|
|
9
|
+
|
|
10
|
+
type WireRow = { columns: string[]; values: WireValue[] }
|
|
11
|
+
|
|
12
|
+
interface JsSyncDb {
|
|
13
|
+
exec(sql: string, params: WireValue[]): void
|
|
14
|
+
query(sql: string, params: WireValue[]): WireRow[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type CompiledQuery = { sql: string; params: WireValue[] }
|
|
18
|
+
|
|
19
|
+
export type AdapterStats = {
|
|
20
|
+
execCalls: number
|
|
21
|
+
queryCalls: number
|
|
22
|
+
sqlMs: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Match transaction control only as the statement itself. CREATE TRIGGER uses
|
|
26
|
+
// `... BEGIN ... END` for its body, which is valid DO SQL and not a transaction.
|
|
27
|
+
const TX_SQL = /^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)(?=\s|;|$)/i
|
|
28
|
+
const NUMBERED_PARAMETER = /\?[0-9]+/
|
|
29
|
+
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER)
|
|
30
|
+
const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER)
|
|
31
|
+
|
|
32
|
+
export function assertHostSql(sql: string): void {
|
|
33
|
+
if (TX_SQL.test(sql)) {
|
|
34
|
+
throw new TypeError('transaction SQL is host-owned and forbidden')
|
|
35
|
+
}
|
|
36
|
+
if (NUMBERED_PARAMETER.test(sql)) {
|
|
37
|
+
throw new TypeError('numbered parameters are forbidden; use positional ? bindings')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decodeBinding(value: WireValue): unknown {
|
|
42
|
+
switch (value.kind) {
|
|
43
|
+
case 'null':
|
|
44
|
+
return null
|
|
45
|
+
case 'integer': {
|
|
46
|
+
const exact = BigInt(value.value)
|
|
47
|
+
// SqlStorage does not accept bigint. Safe values can use Number; unsafe
|
|
48
|
+
// values use decimal text and rely on the destination's INTEGER affinity.
|
|
49
|
+
return exact >= MIN_SAFE && exact <= MAX_SAFE ? Number(exact) : value.value
|
|
50
|
+
}
|
|
51
|
+
case 'real':
|
|
52
|
+
case 'text':
|
|
53
|
+
return value.value
|
|
54
|
+
case 'blob':
|
|
55
|
+
return Uint8Array.from(value.value).buffer
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function encodeResult(value: unknown): WireValue {
|
|
60
|
+
if (value === null) return { kind: 'null' }
|
|
61
|
+
if (typeof value === 'number') {
|
|
62
|
+
return Number.isInteger(value)
|
|
63
|
+
? { kind: 'integer', value: String(value) }
|
|
64
|
+
: { kind: 'real', value }
|
|
65
|
+
}
|
|
66
|
+
if (typeof value === 'string') return { kind: 'text', value }
|
|
67
|
+
if (value instanceof ArrayBuffer) {
|
|
68
|
+
return { kind: 'blob', value: Array.from(new Uint8Array(value)) }
|
|
69
|
+
}
|
|
70
|
+
if (ArrayBuffer.isView(value)) {
|
|
71
|
+
return {
|
|
72
|
+
kind: 'blob',
|
|
73
|
+
value: Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw new TypeError(
|
|
77
|
+
`unsupported SqlStorage result: ${Object.prototype.toString.call(value)}`
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The sole JS SQL adapter used by wasm. It is synchronous, consumes every
|
|
83
|
+
* cursor before returning, accepts positional `?` only, and never starts or
|
|
84
|
+
* finishes a transaction.
|
|
85
|
+
*/
|
|
86
|
+
export class SqlStorageSyncDb implements JsSyncDb {
|
|
87
|
+
readonly stats: AdapterStats = { execCalls: 0, queryCalls: 0, sqlMs: 0 }
|
|
88
|
+
|
|
89
|
+
constructor(private readonly sql: SqlStorage) {}
|
|
90
|
+
|
|
91
|
+
resetStats(): void {
|
|
92
|
+
this.stats.execCalls = 0
|
|
93
|
+
this.stats.queryCalls = 0
|
|
94
|
+
this.stats.sqlMs = 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
exec(sql: string, params: WireValue[]): void {
|
|
98
|
+
assertHostSql(sql)
|
|
99
|
+
const start = performance.now()
|
|
100
|
+
this.sql.exec(sql, ...params.map(decodeBinding))
|
|
101
|
+
this.stats.execCalls++
|
|
102
|
+
this.stats.sqlMs += performance.now() - start
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
query(sql: string, params: WireValue[]): WireRow[] {
|
|
106
|
+
assertHostSql(sql)
|
|
107
|
+
const start = performance.now()
|
|
108
|
+
const cursor = this.sql.exec(sql, ...params.map(decodeBinding))
|
|
109
|
+
const columns = [...cursor.columnNames]
|
|
110
|
+
// A SqlStorage cursor must be fully consumed before any await. Returning a
|
|
111
|
+
// materialized array here makes that property structural.
|
|
112
|
+
const rows = Array.from(cursor.raw()).map((values) => ({
|
|
113
|
+
columns,
|
|
114
|
+
values: values.map(encodeResult),
|
|
115
|
+
}))
|
|
116
|
+
this.stats.queryCalls++
|
|
117
|
+
this.stats.sqlMs += performance.now() - start
|
|
118
|
+
return rows
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Direct application SQL surface used for initialization and admin reads. */
|
|
123
|
+
export class SqlStorageDirect implements SyncSql {
|
|
124
|
+
constructor(private readonly sql: SqlStorage) {}
|
|
125
|
+
|
|
126
|
+
exec(sql: string, params: readonly unknown[] = []): void {
|
|
127
|
+
assertHostSql(sql)
|
|
128
|
+
this.sql.exec(sql, ...params)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
query<Row extends Record<string, unknown> = Record<string, unknown>>(
|
|
132
|
+
sql: string,
|
|
133
|
+
params: readonly unknown[] = []
|
|
134
|
+
): Row[] {
|
|
135
|
+
assertHostSql(sql)
|
|
136
|
+
return this.sql.exec(sql, ...params).toArray() as Row[]
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Async-compatible consumer mutator surface. Each method fully completes and
|
|
142
|
+
* materializes its SqlStorage operation before its promise resolves, so an
|
|
143
|
+
* existing `await tx.query(...)` cannot carry a cursor across an await.
|
|
144
|
+
*/
|
|
145
|
+
export class SqlStorageMutatorTransaction implements MutatorSql {
|
|
146
|
+
constructor(
|
|
147
|
+
private readonly direct: SqlStorageDirect,
|
|
148
|
+
private readonly compileQuery: (ast: unknown) => CompiledQuery
|
|
149
|
+
) {}
|
|
150
|
+
|
|
151
|
+
async exec(sql: string, params: readonly unknown[] = []): Promise<void> {
|
|
152
|
+
this.direct.exec(sql, params)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async query<Row extends Record<string, unknown> = Record<string, unknown>>(
|
|
156
|
+
sql: string,
|
|
157
|
+
params: readonly unknown[] = []
|
|
158
|
+
): Promise<Row[]> {
|
|
159
|
+
return this.direct.query<Row>(sql, params)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async queryAst<Row extends Record<string, unknown> = Record<string, unknown>>(
|
|
163
|
+
ast: unknown
|
|
164
|
+
): Promise<Row[]> {
|
|
165
|
+
const compiled = this.compileQuery(ast)
|
|
166
|
+
return this.direct.query<Row>(compiled.sql, compiled.params.map(decodeBinding))
|
|
167
|
+
}
|
|
168
|
+
}
|