on-zero 0.7.1 → 0.7.2
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/dist/cjs/createZeroSQLiteServer.test.cjs +42 -0
- package/dist/cjs/createZeroSQLiteServer.test.native.js +45 -0
- package/dist/cjs/createZeroSQLiteServer.test.native.js.map +1 -1
- package/dist/cjs/httpPull/transport.test.cjs +109 -0
- package/dist/cjs/httpPull/transport.test.native.js +148 -0
- package/dist/cjs/httpPull/transport.test.native.js.map +1 -1
- package/dist/cjs/httpPullTransport.cjs +41 -24
- package/dist/cjs/httpPullTransport.native.js +47 -26
- package/dist/cjs/httpPullTransport.native.js.map +1 -1
- package/dist/cjs/mutations.cjs +5 -1
- package/dist/cjs/mutations.native.js +5 -1
- package/dist/cjs/mutations.native.js.map +1 -1
- package/dist/cjs/mutations.test.cjs +28 -0
- package/dist/cjs/mutations.test.native.js +40 -0
- package/dist/cjs/mutations.test.native.js.map +1 -0
- package/dist/cjs/sqliteRuntime.cjs +2 -2
- package/dist/cjs/sqliteRuntime.native.js +4 -4
- package/dist/cjs/sqliteRuntime.native.js.map +1 -1
- package/dist/esm/createZeroSQLiteServer.test.mjs +42 -0
- package/dist/esm/createZeroSQLiteServer.test.mjs.map +1 -1
- package/dist/esm/createZeroSQLiteServer.test.native.js +45 -0
- package/dist/esm/createZeroSQLiteServer.test.native.js.map +1 -1
- package/dist/esm/httpPull/transport.test.mjs +109 -0
- package/dist/esm/httpPull/transport.test.mjs.map +1 -1
- package/dist/esm/httpPull/transport.test.native.js +148 -0
- package/dist/esm/httpPull/transport.test.native.js.map +1 -1
- package/dist/esm/httpPullTransport.mjs +41 -24
- package/dist/esm/httpPullTransport.mjs.map +1 -1
- package/dist/esm/httpPullTransport.native.js +47 -26
- package/dist/esm/httpPullTransport.native.js.map +1 -1
- package/dist/esm/mutations.mjs +5 -1
- package/dist/esm/mutations.mjs.map +1 -1
- package/dist/esm/mutations.native.js +5 -1
- package/dist/esm/mutations.native.js.map +1 -1
- package/dist/esm/mutations.test.mjs +29 -0
- package/dist/esm/mutations.test.mjs.map +1 -0
- package/dist/esm/mutations.test.native.js +38 -0
- package/dist/esm/mutations.test.native.js.map +1 -0
- package/dist/esm/sqliteRuntime.mjs +2 -2
- package/dist/esm/sqliteRuntime.mjs.map +1 -1
- package/dist/esm/sqliteRuntime.native.js +4 -4
- package/dist/esm/sqliteRuntime.native.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +2 -2
- package/src/createZeroSQLiteServer.test.ts +43 -0
- package/src/httpPull/transport.test.ts +132 -0
- package/src/httpPullTransport.ts +74 -42
- package/src/mutations.test.ts +38 -0
- package/src/mutations.ts +10 -2
- package/src/sqliteRuntime.ts +4 -4
- package/types/httpPullTransport.d.ts +14 -1
- package/types/httpPullTransport.d.ts.map +1 -1
- package/types/mutations.d.ts.map +1 -1
- package/types/mutations.test.d.ts +2 -0
- package/types/mutations.test.d.ts.map +1 -0
|
@@ -249,6 +249,49 @@ describe('createZeroSQLiteServer', () => {
|
|
|
249
249
|
expect(effectRuns).toBe(1)
|
|
250
250
|
})
|
|
251
251
|
|
|
252
|
+
test('keeps later writes after inserting an existing primary key', async () => {
|
|
253
|
+
db.prepare('INSERT INTO project (id, ownerId, name) VALUES (?, ?, ?)').run(
|
|
254
|
+
'p1',
|
|
255
|
+
'u1',
|
|
256
|
+
'original',
|
|
257
|
+
)
|
|
258
|
+
db.prepare('INSERT INTO project (id, ownerId, name) VALUES (?, ?, ?)').run(
|
|
259
|
+
'p2',
|
|
260
|
+
'u1',
|
|
261
|
+
'before',
|
|
262
|
+
)
|
|
263
|
+
const server = createZeroSQLiteServer({
|
|
264
|
+
schema: zeroHttpFixtureSchema,
|
|
265
|
+
models: {
|
|
266
|
+
project: {
|
|
267
|
+
mutate: {
|
|
268
|
+
converge: async ({ tx }) => {
|
|
269
|
+
await tx.mutate.project.insert({
|
|
270
|
+
id: 'p1',
|
|
271
|
+
ownerId: 'u2',
|
|
272
|
+
name: 'replacement',
|
|
273
|
+
})
|
|
274
|
+
await tx.mutate.project.update({ id: 'p2', name: 'after' })
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
createServerActions: () => ({}),
|
|
280
|
+
transactionProvider,
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const result = await server.handleMutationRequest({
|
|
284
|
+
authData: null,
|
|
285
|
+
request: pushRequest(1, 'project.converge', {}),
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
expect((result.response as any).mutations[0].result).toEqual({})
|
|
289
|
+
expect(db.prepare('SELECT * FROM project ORDER BY id').all()).toEqual([
|
|
290
|
+
{ id: 'p1', ownerId: 'u1', name: 'original' },
|
|
291
|
+
{ id: 'p2', ownerId: 'u1', name: 'after' },
|
|
292
|
+
])
|
|
293
|
+
})
|
|
294
|
+
|
|
252
295
|
test('rolls back row writes and persists an application error with its LMID', async () => {
|
|
253
296
|
const server = createZeroSQLiteServer({
|
|
254
297
|
schema: zeroHttpFixtureSchema,
|
|
@@ -20,10 +20,13 @@ type RequestRecord = {
|
|
|
20
20
|
const zeros: Zero<any, any>[] = []
|
|
21
21
|
const transports: Array<{ uninstall(): void }> = []
|
|
22
22
|
let storageID = 0
|
|
23
|
+
let restoreNativeWebSocket: (() => void) | undefined
|
|
23
24
|
|
|
24
25
|
afterEach(async () => {
|
|
25
26
|
while (zeros.length) await zeros.pop()?.close()
|
|
26
27
|
while (transports.length) transports.pop()?.uninstall()
|
|
28
|
+
restoreNativeWebSocket?.()
|
|
29
|
+
restoreNativeWebSocket = undefined
|
|
27
30
|
vi.useRealTimers()
|
|
28
31
|
})
|
|
29
32
|
|
|
@@ -662,6 +665,90 @@ describe('zero-http transport', () => {
|
|
|
662
665
|
expect(errors).toEqual([])
|
|
663
666
|
})
|
|
664
667
|
|
|
668
|
+
test('authenticated wake appends a freshly minted token when the socket opens', async () => {
|
|
669
|
+
const wakeSockets = useFakeNativeWebSocket()
|
|
670
|
+
const getToken = vi.fn(async () => 'signed token&scope=one')
|
|
671
|
+
const fetch = unchangedPullFetch()
|
|
672
|
+
const transport = installHttpPullTransport({
|
|
673
|
+
origin: ORIGIN,
|
|
674
|
+
fetch,
|
|
675
|
+
wake: { getToken },
|
|
676
|
+
})
|
|
677
|
+
transports.push(transport)
|
|
678
|
+
|
|
679
|
+
openRawSocketWithMessages()
|
|
680
|
+
|
|
681
|
+
await eventually(() => expect(wakeSockets).toHaveLength(1))
|
|
682
|
+
expect(getToken).toHaveBeenCalledTimes(1)
|
|
683
|
+
expect(wakeSockets[0].url).toBe(
|
|
684
|
+
'wss://zero-http.local/wake?clientID=c1&wakeToken=signed%20token%26scope%3Done',
|
|
685
|
+
)
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
test('authenticated wake gets a fresh token for every reconnect attempt', async () => {
|
|
689
|
+
const wakeSockets = useFakeNativeWebSocket()
|
|
690
|
+
const getToken = vi
|
|
691
|
+
.fn<() => Promise<string>>()
|
|
692
|
+
.mockResolvedValueOnce('wake-token-1')
|
|
693
|
+
.mockResolvedValueOnce('wake-token-2')
|
|
694
|
+
const transport = installHttpPullTransport({
|
|
695
|
+
origin: ORIGIN,
|
|
696
|
+
fetch: unchangedPullFetch(),
|
|
697
|
+
wake: { getToken },
|
|
698
|
+
})
|
|
699
|
+
transports.push(transport)
|
|
700
|
+
|
|
701
|
+
openRawSocketWithMessages()
|
|
702
|
+
await eventually(() => expect(wakeSockets).toHaveLength(1))
|
|
703
|
+
wakeSockets[0].onerror?.()
|
|
704
|
+
|
|
705
|
+
await eventually(() => expect(wakeSockets).toHaveLength(2), 1_000)
|
|
706
|
+
expect(getToken).toHaveBeenCalledTimes(2)
|
|
707
|
+
expect(wakeSockets.map((socket) => socket.url)).toEqual([
|
|
708
|
+
'wss://zero-http.local/wake?clientID=c1&wakeToken=wake-token-1',
|
|
709
|
+
'wss://zero-http.local/wake?clientID=c1&wakeToken=wake-token-2',
|
|
710
|
+
])
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
test('wake token rejection leaves pulls healthy and retries the advisory channel', async () => {
|
|
714
|
+
const wakeSockets = useFakeNativeWebSocket()
|
|
715
|
+
const getToken = vi.fn(async () => {
|
|
716
|
+
throw new Error('mint route unavailable')
|
|
717
|
+
})
|
|
718
|
+
const fetch = unchangedPullFetch()
|
|
719
|
+
const transport = installHttpPullTransport({
|
|
720
|
+
origin: ORIGIN,
|
|
721
|
+
fetch,
|
|
722
|
+
wake: { getToken },
|
|
723
|
+
})
|
|
724
|
+
transports.push(transport)
|
|
725
|
+
|
|
726
|
+
openRawSocketWithMessages()
|
|
727
|
+
await eventually(() => expect(fetch).toHaveBeenCalled())
|
|
728
|
+
const pullsBeforeManualPull = fetch.mock.calls.length
|
|
729
|
+
|
|
730
|
+
await expect(transport.pull()).resolves.toBeUndefined()
|
|
731
|
+
expect(fetch.mock.calls.length).toBeGreaterThan(pullsBeforeManualPull)
|
|
732
|
+
await eventually(() => expect(getToken).toHaveBeenCalledTimes(2), 1_000)
|
|
733
|
+
expect(wakeSockets).toHaveLength(0)
|
|
734
|
+
expect(transport.connections).toBe(1)
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
test('wake true preserves the bare unauthenticated socket URL', async () => {
|
|
738
|
+
const wakeSockets = useFakeNativeWebSocket()
|
|
739
|
+
const transport = installHttpPullTransport({
|
|
740
|
+
origin: ORIGIN,
|
|
741
|
+
fetch: unchangedPullFetch(),
|
|
742
|
+
wake: true,
|
|
743
|
+
})
|
|
744
|
+
transports.push(transport)
|
|
745
|
+
|
|
746
|
+
openRawSocketWithMessages()
|
|
747
|
+
|
|
748
|
+
await eventually(() => expect(wakeSockets).toHaveLength(1))
|
|
749
|
+
expect(wakeSockets[0].url).toBe('wss://zero-http.local/wake?clientID=c1')
|
|
750
|
+
})
|
|
751
|
+
|
|
665
752
|
test('non-origin WebSockets pass through to the native implementation', () => {
|
|
666
753
|
const previous = globalThis.WebSocket
|
|
667
754
|
class NativeWebSocket {
|
|
@@ -832,6 +919,51 @@ function jsonResponse(body: unknown, init?: ResponseInit) {
|
|
|
832
919
|
})
|
|
833
920
|
}
|
|
834
921
|
|
|
922
|
+
function unchangedPullFetch() {
|
|
923
|
+
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
924
|
+
const request = recordRequest(input, init)
|
|
925
|
+
expect(request.path).toBe('/pull')
|
|
926
|
+
return jsonResponse({ cookie: request.body.cookie, unchanged: true })
|
|
927
|
+
})
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function useFakeNativeWebSocket() {
|
|
931
|
+
const previous = globalThis.WebSocket
|
|
932
|
+
const sockets: Array<{
|
|
933
|
+
url: string
|
|
934
|
+
onmessage: (() => void) | null
|
|
935
|
+
onclose: (() => void) | null
|
|
936
|
+
onerror: (() => void) | null
|
|
937
|
+
}> = []
|
|
938
|
+
|
|
939
|
+
class FakeNativeWebSocket {
|
|
940
|
+
static CONNECTING = 0
|
|
941
|
+
static OPEN = 1
|
|
942
|
+
static CLOSING = 2
|
|
943
|
+
static CLOSED = 3
|
|
944
|
+
|
|
945
|
+
readonly url: string
|
|
946
|
+
onmessage: (() => void) | null = null
|
|
947
|
+
onclose: (() => void) | null = null
|
|
948
|
+
onerror: (() => void) | null = null
|
|
949
|
+
|
|
950
|
+
constructor(url: string | URL) {
|
|
951
|
+
this.url = String(url)
|
|
952
|
+
sockets.push(this)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
close() {
|
|
956
|
+
this.onclose?.()
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
globalThis.WebSocket = FakeNativeWebSocket as unknown as typeof WebSocket
|
|
961
|
+
restoreNativeWebSocket = () => {
|
|
962
|
+
globalThis.WebSocket = previous
|
|
963
|
+
}
|
|
964
|
+
return sockets
|
|
965
|
+
}
|
|
966
|
+
|
|
835
967
|
async function waitForComplete<T>(view: {
|
|
836
968
|
addListener(listener: (data: any, resultType: string) => void): () => void
|
|
837
969
|
}): Promise<T> {
|
package/src/httpPullTransport.ts
CHANGED
|
@@ -78,7 +78,7 @@ type TransportState = {
|
|
|
78
78
|
readonly nativeWebSocket: WebSocketConstructor | undefined
|
|
79
79
|
readonly sockets: Set<ZeroHttpSocket>
|
|
80
80
|
readonly pullIntervalMs: number | undefined
|
|
81
|
-
readonly
|
|
81
|
+
readonly wake: HttpPullTransportOptions['wake']
|
|
82
82
|
readonly queryTransform: QueryTransform | undefined
|
|
83
83
|
readonly queryForward: boolean
|
|
84
84
|
readonly queryAware: boolean
|
|
@@ -117,12 +117,18 @@ export type HttpPullTransportOptions = {
|
|
|
117
117
|
// when set, every open connection also pulls on this interval so
|
|
118
118
|
// server-initiated changes arrive without a client-side trigger
|
|
119
119
|
pullIntervalMs?: number
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
120
|
+
/**
|
|
121
|
+
* opens a notification-only socket to <origin>/wake and pulls immediately on
|
|
122
|
+
* any wake, demoting interval polling to a safety net. true preserves the
|
|
123
|
+
* bare unauthenticated URL. for an authenticated host, pass getToken: each
|
|
124
|
+
* socket attempt calls it afresh and appends the result as wakeToken because
|
|
125
|
+
* browser WebSockets cannot send headers. consumers should implement
|
|
126
|
+
* getToken by calling an authenticated edge route that mints a short-lived,
|
|
127
|
+
* namespace-scoped signed token; the consumer's authorizeWake callback must
|
|
128
|
+
* verify that token. mint failures leave this advisory channel down and retry
|
|
129
|
+
* with backoff; pulls remain the source of correctness.
|
|
130
|
+
*/
|
|
131
|
+
wake?: boolean | { getToken(): Promise<string> }
|
|
126
132
|
// when provided, the query-aware extension is on and desired queries are
|
|
127
133
|
// resolved client-side to an AST before shipping (native host / trusted
|
|
128
134
|
// harness). omit for the baseline dialect (client-local got-query synthesis).
|
|
@@ -132,7 +138,8 @@ export type HttpPullTransportOptions = {
|
|
|
132
138
|
// auth. the production path for permission-transformed queries.
|
|
133
139
|
queryForward?: boolean
|
|
134
140
|
// receives the structured connection lifecycle. when omitted, on-zero logs
|
|
135
|
-
//
|
|
141
|
+
// failures so production errors retain their owners without logging routine
|
|
142
|
+
// connection and mutation traffic.
|
|
136
143
|
lifecycle?: (event: HttpPullLifecycleEvent) => void
|
|
137
144
|
}
|
|
138
145
|
|
|
@@ -200,8 +207,8 @@ function getHttpPullPageRegistry(): HttpPullPageRegistry {
|
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
function logHttpPullLifecycle(event: HttpPullLifecycleEvent) {
|
|
203
|
-
if (process.env.NODE_ENV === 'test') return
|
|
204
|
-
console.
|
|
210
|
+
if (process.env.NODE_ENV === 'test' || event.type !== 'failure') return
|
|
211
|
+
console.error(`[on-zero:http-pull] ${JSON.stringify(event)}`)
|
|
205
212
|
}
|
|
206
213
|
|
|
207
214
|
export function installHttpPullTransport(
|
|
@@ -233,7 +240,7 @@ export function installHttpPullTransport(
|
|
|
233
240
|
nativeWebSocket: previousWebSocket,
|
|
234
241
|
sockets: new Set(),
|
|
235
242
|
pullIntervalMs: opts.pullIntervalMs,
|
|
236
|
-
|
|
243
|
+
wake: opts.wake ?? false,
|
|
237
244
|
queryTransform: opts.queryTransform,
|
|
238
245
|
queryForward: opts.queryForward === true,
|
|
239
246
|
queryAware: opts.queryTransform !== undefined || opts.queryForward === true,
|
|
@@ -338,6 +345,7 @@ class ZeroHttpSocket {
|
|
|
338
345
|
private openTimer: ReturnType<typeof setTimeout> | undefined
|
|
339
346
|
private pullTimer: ReturnType<typeof setInterval> | undefined
|
|
340
347
|
private wakeSocket: { close(): void } | undefined
|
|
348
|
+
private wakeConnecting = false
|
|
341
349
|
private wakeReconnectTimer: ReturnType<typeof setTimeout> | undefined
|
|
342
350
|
private readonly generation: number
|
|
343
351
|
private readonly zeroInstanceID: string
|
|
@@ -491,7 +499,7 @@ class ZeroHttpSocket {
|
|
|
491
499
|
this.run(this.pull())
|
|
492
500
|
}, this.state.pullIntervalMs)
|
|
493
501
|
}
|
|
494
|
-
if (this.state.
|
|
502
|
+
if (this.state.wake) this.openWakeChannel()
|
|
495
503
|
}
|
|
496
504
|
|
|
497
505
|
// notification-only wake channel: a real WebSocket to <origin>/wake that
|
|
@@ -501,38 +509,62 @@ class ZeroHttpSocket {
|
|
|
501
509
|
// interval poll remains the safety net that guarantees convergence.
|
|
502
510
|
private openWakeChannel() {
|
|
503
511
|
const Native = this.state.nativeWebSocket
|
|
504
|
-
if (
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
onerror: (() => void) | null
|
|
511
|
-
close(): void
|
|
512
|
-
}
|
|
513
|
-
try {
|
|
514
|
-
socket = new Native(url) as unknown as typeof socket
|
|
515
|
-
} catch {
|
|
512
|
+
if (
|
|
513
|
+
!Native ||
|
|
514
|
+
this.wakeSocket ||
|
|
515
|
+
this.wakeConnecting ||
|
|
516
|
+
this.readyState !== this.OPEN
|
|
517
|
+
) {
|
|
516
518
|
return
|
|
517
519
|
}
|
|
518
|
-
this.
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
520
|
+
this.wakeConnecting = true
|
|
521
|
+
const wsBase = this.state.originString.replace(/^http/, 'ws')
|
|
522
|
+
void (async () => {
|
|
523
|
+
let wakeToken: string | undefined
|
|
524
|
+
let socket: {
|
|
525
|
+
onmessage: (() => void) | null
|
|
526
|
+
onclose: (() => void) | null
|
|
527
|
+
onerror: (() => void) | null
|
|
528
|
+
close(): void
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
if (typeof this.state.wake === 'object') {
|
|
532
|
+
wakeToken = await this.state.wake.getToken()
|
|
533
|
+
}
|
|
534
|
+
if (this.readyState !== this.OPEN || this.wakeSocket) return
|
|
535
|
+
const url =
|
|
536
|
+
`${wsBase}/wake?clientID=${encodeURIComponent(this.clientID)}` +
|
|
537
|
+
(wakeToken === undefined ? '' : `&wakeToken=${encodeURIComponent(wakeToken)}`)
|
|
538
|
+
socket = new Native(url) as unknown as typeof socket
|
|
539
|
+
} catch {
|
|
540
|
+
this.scheduleWakeReconnect()
|
|
541
|
+
return
|
|
542
|
+
} finally {
|
|
543
|
+
this.wakeConnecting = false
|
|
544
|
+
}
|
|
545
|
+
this.wakeSocket = socket
|
|
546
|
+
const reconnect = () => {
|
|
547
|
+
if (this.wakeSocket !== socket) return
|
|
548
|
+
this.wakeSocket = undefined
|
|
549
|
+
this.scheduleWakeReconnect()
|
|
550
|
+
}
|
|
551
|
+
// route through requestPullAfterCurrent, NOT pull() directly: a wake that
|
|
552
|
+
// lands while a pull is already in flight must set pullAfterCurrent so the
|
|
553
|
+
// in-flight pull re-runs and picks up the woken change. calling pull()
|
|
554
|
+
// directly would return the existing promise and silently drop the wake,
|
|
555
|
+
// leaving convergence to the safety poll (a burst-storm latency bug).
|
|
556
|
+
socket.onmessage = () => this.requestPullAfterCurrent()
|
|
557
|
+
socket.onclose = reconnect
|
|
558
|
+
socket.onerror = reconnect
|
|
559
|
+
})()
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private scheduleWakeReconnect() {
|
|
563
|
+
if (this.readyState !== this.OPEN || this.wakeReconnectTimer) return
|
|
564
|
+
this.wakeReconnectTimer = setTimeout(() => {
|
|
565
|
+
this.wakeReconnectTimer = undefined
|
|
566
|
+
this.openWakeChannel()
|
|
567
|
+
}, 500)
|
|
536
568
|
}
|
|
537
569
|
|
|
538
570
|
private closeWakeChannel() {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { mutations } from './mutations'
|
|
4
|
+
import { serverWhere } from './serverWhere'
|
|
5
|
+
|
|
6
|
+
describe('mutations registry', () => {
|
|
7
|
+
test('two modules registering the same table keep both custom mutators', () => {
|
|
8
|
+
// a template commonly splits a table's mutators across files (the table's
|
|
9
|
+
// own mutations file plus a seed.ts firing a demo seed on that table).
|
|
10
|
+
// the registry is keyed by table, so a wholesale replace made whichever
|
|
11
|
+
// module imported LAST win: alphabetical import order silently dropped
|
|
12
|
+
// seed.ts's seedDemo whenever the seed file sorted before the table file.
|
|
13
|
+
const permissions = serverWhere('todo', () => true)
|
|
14
|
+
|
|
15
|
+
const seedModule = mutations('todo', permissions, {
|
|
16
|
+
seedDemo: async () => {},
|
|
17
|
+
})
|
|
18
|
+
const tableModule = mutations('todo', permissions, {})
|
|
19
|
+
|
|
20
|
+
expect(typeof seedModule.seedDemo).toBe('function')
|
|
21
|
+
expect(typeof tableModule.insert).toBe('function')
|
|
22
|
+
// both proxies read the same per-table registry: the later CRUD-only
|
|
23
|
+
// registration must not clobber the earlier custom mutator
|
|
24
|
+
expect(typeof (tableModule as Record<string, unknown>).seedDemo).toBe('function')
|
|
25
|
+
expect(Object.keys(tableModule)).toContain('seedDemo')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('re-registering a handler replaces it per key (HMR)', () => {
|
|
29
|
+
const permissions = serverWhere('post', () => true)
|
|
30
|
+
const v1 = async () => {}
|
|
31
|
+
const v2 = async () => {}
|
|
32
|
+
mutations('post', permissions, { custom: v1 })
|
|
33
|
+
const proxy = mutations('post', permissions, { custom: v2 })
|
|
34
|
+
// per-key merge must still take the newest registration for an edited
|
|
35
|
+
// handler, otherwise HMR would pin the stale implementation
|
|
36
|
+
expect(proxy.custom).toBe(v2)
|
|
37
|
+
})
|
|
38
|
+
})
|
package/src/mutations.ts
CHANGED
|
@@ -39,8 +39,16 @@ function getOrCreateMutationProxy<T extends Record<string, Function>>(
|
|
|
39
39
|
tableName: string,
|
|
40
40
|
implementations: T,
|
|
41
41
|
): T {
|
|
42
|
-
//
|
|
43
|
-
|
|
42
|
+
// merge with any prior registration for this table: multiple modules may
|
|
43
|
+
// register mutators on the same table (a seed.ts alongside the table's own
|
|
44
|
+
// mutations file) and module import order is arbitrary, so replacing
|
|
45
|
+
// wholesale drops whichever module registered first. per-key replacement
|
|
46
|
+
// still supports HMR updates of edited handlers.
|
|
47
|
+
const prior = mutationRegistry().get(tableName)
|
|
48
|
+
mutationRegistry().set(
|
|
49
|
+
tableName,
|
|
50
|
+
prior ? { ...prior, ...implementations } : implementations,
|
|
51
|
+
)
|
|
44
52
|
|
|
45
53
|
// return existing proxy if we have one (HMR case)
|
|
46
54
|
const existing = proxyRegistry().get(tableName)
|
package/src/sqliteRuntime.ts
CHANGED
|
@@ -257,9 +257,12 @@ async function executeCrud<Schema extends ZeroSchema>(
|
|
|
257
257
|
const insert = `INSERT INTO ${serverTableName} (${serverColumns.join(
|
|
258
258
|
', ',
|
|
259
259
|
)}) VALUES (${columnNames.map(() => '?').join(', ')})`
|
|
260
|
+
const conflictColumns = table.primaryKey
|
|
261
|
+
.map((columnName) => quoteSQLiteIdentifier(serverColumn(table, columnName)))
|
|
262
|
+
.join(', ')
|
|
260
263
|
|
|
261
264
|
if (kind === 'insert') {
|
|
262
|
-
await executor.exec(insert
|
|
265
|
+
await executor.exec(`${insert} ON CONFLICT (${conflictColumns}) DO NOTHING`, params, {
|
|
263
266
|
table: table.serverName ?? table.name,
|
|
264
267
|
publicTable: tableName,
|
|
265
268
|
kind,
|
|
@@ -271,9 +274,6 @@ async function executeCrud<Schema extends ZeroSchema>(
|
|
|
271
274
|
const mutableColumns = columnNames.filter(
|
|
272
275
|
(columnName) => !table.primaryKey.includes(columnName),
|
|
273
276
|
)
|
|
274
|
-
const conflictColumns = table.primaryKey
|
|
275
|
-
.map((columnName) => quoteSQLiteIdentifier(serverColumn(table, columnName)))
|
|
276
|
-
.join(', ')
|
|
277
277
|
const conflictAction =
|
|
278
278
|
mutableColumns.length === 0
|
|
279
279
|
? 'DO NOTHING'
|
|
@@ -13,7 +13,20 @@ export type HttpPullTransportOptions = {
|
|
|
13
13
|
pushOrigin?: string;
|
|
14
14
|
fetch?: typeof fetch;
|
|
15
15
|
pullIntervalMs?: number;
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* opens a notification-only socket to <origin>/wake and pulls immediately on
|
|
18
|
+
* any wake, demoting interval polling to a safety net. true preserves the
|
|
19
|
+
* bare unauthenticated URL. for an authenticated host, pass getToken: each
|
|
20
|
+
* socket attempt calls it afresh and appends the result as wakeToken because
|
|
21
|
+
* browser WebSockets cannot send headers. consumers should implement
|
|
22
|
+
* getToken by calling an authenticated edge route that mints a short-lived,
|
|
23
|
+
* namespace-scoped signed token; the consumer's authorizeWake callback must
|
|
24
|
+
* verify that token. mint failures leave this advisory channel down and retry
|
|
25
|
+
* with backoff; pulls remain the source of correctness.
|
|
26
|
+
*/
|
|
27
|
+
wake?: boolean | {
|
|
28
|
+
getToken(): Promise<string>;
|
|
29
|
+
};
|
|
17
30
|
queryTransform?: QueryTransform;
|
|
18
31
|
queryForward?: boolean;
|
|
19
32
|
lifecycle?: (event: HttpPullLifecycleEvent) => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpPullTransport.d.ts","sourceRoot":"","sources":["../src/httpPullTransport.ts"],"names":[],"mappings":"AAUA,KAAK,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAA;AA+B7D,MAAM,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,OAAO,EAAE,KAAK,OAAO,CAAA;AA4DhF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,SAAS,IAAI,IAAI,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM,EAAE,MAAM,CAAA;IAGd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;IAGpB,cAAc,CAAC,EAAE,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"httpPullTransport.d.ts","sourceRoot":"","sources":["../src/httpPullTransport.ts"],"names":[],"mappings":"AAUA,KAAK,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAA;AA+B7D,MAAM,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,OAAO,EAAE,KAAK,OAAO,CAAA;AA4DhF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,SAAS,IAAI,IAAI,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM,EAAE,MAAM,CAAA;IAGd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;IAGpB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;;;;;;;;;OAUG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG;QAAE,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;KAAE,CAAA;IAIhD,cAAc,CAAC,EAAE,cAAc,CAAA;IAI/B,YAAY,CAAC,EAAE,OAAO,CAAA;IAItB,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,IAAI,CAAA;CACpD,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,EACA,SAAS,GACT,UAAU,GACV,MAAM,GACN,OAAO,GACP,SAAS,GACT,YAAY,GACZ,SAAS,GACT,MAAM,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,cAAc,EAAE,MAAM,CAAA;IACtB,aAAa,EAAE,MAAM,CAAA;IACrB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,gBAAgB,EAAE,MAAM,CAAA;IACxB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;IACpC,YAAY,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAwCD,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,wBAAwB,GAC7B,iBAAiB,CA6EnB;AAKD,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,wBAAwB,GAC7B,iBAAiB,CAQnB"}
|
package/types/mutations.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mutations.d.ts","sourceRoot":"","sources":["../src/mutations.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAA;AAC7D,OAAO,KAAK,EACV,cAAc,EACd,MAAM,EACN,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACN,MAAM,SAAS,CAAA;AAGhB,KAAK,kBAAkB,CAAC,KAAK,SAAS,SAAS,IAAI,uBAAuB,CACxE,MAAM,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,CACxB,CAAA;
|
|
1
|
+
{"version":3,"file":"mutations.d.ts","sourceRoot":"","sources":["../src/mutations.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAA;AAC7D,OAAO,KAAK,EACV,cAAc,EACd,MAAM,EACN,cAAc,EACd,SAAS,EACT,cAAc,EACd,KAAK,EACN,MAAM,SAAS,CAAA;AAGhB,KAAK,kBAAkB,CAAC,KAAK,SAAS,SAAS,IAAI,uBAAuB,CACxE,MAAM,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,CACxB,CAAA;AAmED,KAAK,eAAe,CAAC,GAAG,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,cAAc,EAAE,GAAG,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;AACnF,KAAK,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAA;AAevD,KAAK,YAAY,GAAG,uBAAuB,CAAC,GAAG,CAAC,CAAA;AAEhD,KAAK,aAAa,CAAC,KAAK,SAAS,YAAY,IAAI;IAC/C,MAAM,EAAE,eAAe,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAA;IAC9C,MAAM,EAAE,eAAe,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAA;IAC9C,MAAM,EAAE,eAAe,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAA;IAC9C,MAAM,EAAE,eAAe,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAA;CAC/C,CAAA;AAED,KAAK,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAA;AAE1D,KAAK,iBAAiB,CAAC,KAAK,SAAS,YAAY,EAAE,SAAS,SAAS,gBAAgB,IAAI;KACtF,GAAG,IAAI,SAAS,GAAG,MAAM,SAAS,GAAG,GAAG,SAAS,MAAM,SAAS,GAC7D,SAAS,CAAC,GAAG,CAAC,GACd,GAAG,SAAS,MAAM,aAAa,CAAC,GAAG,CAAC,GAClC,aAAa,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,GACzB,KAAK;CACZ,CAAA;AAED,wBAAgB,SAAS,CAAC,SAAS,SAAS,gBAAgB,EAC1D,SAAS,EAAE,SAAS,GACnB,SAAS,CAAA;AACZ,wBAAgB,SAAS,CAAC,KAAK,SAAS,YAAY,EAAE,WAAW,SAAS,KAAK,EAC7E,KAAK,EAAE,KAAK,EACZ,WAAW,EAAE,WAAW,GACvB,iBAAiB,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;AAC/B,wBAAgB,SAAS,CACvB,KAAK,SAAS,YAAY,EAC1B,WAAW,SAAS,KAAK,EACzB,SAAS,SAAS,gBAAgB,EAElC,KAAK,EAAE,KAAK,EACZ,WAAW,EAAE,WAAW,EACxB,SAAS,EAAE,SAAS,GACnB,iBAAiB,CAAC,KAAK,EAAE,SAAS,CAAC,CAAA;AAEtC,wBAAgB,SAAS,CAAC,KAAK,SAAS,SAAS,EAAE,WAAW,SAAS,KAAK,EAC1E,SAAS,EAAE,KAAK,EAChB,WAAW,EAAE,WAAW,GACvB,iBAAiB,CAAC,kBAAkB,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,CAAA;AACnD,wBAAgB,SAAS,CACvB,KAAK,SAAS,SAAS,EACvB,WAAW,SAAS,KAAK,EACzB,SAAS,SAAS,gBAAgB,EAElC,SAAS,EAAE,KAAK,EAChB,WAAW,EAAE,WAAW,EACxB,SAAS,EAAE,SAAS,GACnB,iBAAiB,CAAC,kBAAkB,CAAC,KAAK,CAAC,EAAE,SAAS,CAAC,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mutations.test.d.ts","sourceRoot":"","sources":["../src/mutations.test.ts"],"names":[],"mappings":""}
|