on-zero 0.5.1 → 0.6.1
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/createUseQuery.cjs +2 -1
- package/dist/cjs/createUseQuery.native.js +2 -1
- package/dist/cjs/createUseQuery.native.js.map +1 -1
- package/dist/cjs/createUseQueryDirect.cjs +1 -1
- package/dist/cjs/createUseQueryDirect.native.js +1 -1
- package/dist/cjs/createUseQueryDirect.native.js.map +1 -1
- package/dist/cjs/createZeroClient.cjs +45 -15
- package/dist/cjs/createZeroClient.native.js +56 -25
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/createZeroClient.recovery.test.cjs +96 -0
- package/dist/cjs/createZeroClient.recovery.test.native.js +130 -0
- package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -0
- package/dist/cjs/createZeroServer.cjs +33 -0
- package/dist/cjs/createZeroServer.native.js +40 -0
- package/dist/cjs/createZeroServer.native.js.map +1 -1
- package/dist/cjs/createZeroServer.test.cjs +162 -0
- package/dist/cjs/createZeroServer.test.native.js +217 -0
- package/dist/cjs/createZeroServer.test.native.js.map +1 -0
- package/dist/cjs/httpPull/transport.test.cjs +1 -3
- package/dist/cjs/httpPull/transport.test.native.js +2 -4
- package/dist/cjs/httpPull/transport.test.native.js.map +1 -1
- package/dist/cjs/mutations.cjs +2 -2
- package/dist/cjs/mutations.native.js +2 -2
- package/dist/cjs/mutations.native.js.map +1 -1
- package/dist/cjs/provideZero.hydration.test.cjs +5 -0
- package/dist/cjs/provideZero.hydration.test.native.js +5 -0
- package/dist/cjs/provideZero.hydration.test.native.js.map +1 -1
- package/dist/cjs/testSetup.cjs +15 -0
- package/dist/cjs/testSetup.native.js +15 -0
- package/dist/cjs/testSetup.native.js.map +1 -1
- package/dist/cjs/useQuery.empty.test.cjs +5 -0
- package/dist/cjs/useQuery.empty.test.native.js +5 -0
- package/dist/cjs/useQuery.empty.test.native.js.map +1 -1
- package/dist/esm/createUseQuery.mjs +2 -1
- package/dist/esm/createUseQuery.mjs.map +1 -1
- package/dist/esm/createUseQuery.native.js +2 -1
- package/dist/esm/createUseQuery.native.js.map +1 -1
- package/dist/esm/createUseQueryDirect.mjs +2 -2
- package/dist/esm/createUseQueryDirect.mjs.map +1 -1
- package/dist/esm/createUseQueryDirect.native.js +2 -2
- package/dist/esm/createUseQueryDirect.native.js.map +1 -1
- package/dist/esm/createZeroClient.mjs +46 -16
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +57 -26
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.mjs +97 -0
- package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -0
- package/dist/esm/createZeroClient.recovery.test.native.js +128 -0
- package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -0
- package/dist/esm/createZeroServer.mjs +33 -0
- package/dist/esm/createZeroServer.mjs.map +1 -1
- package/dist/esm/createZeroServer.native.js +40 -0
- package/dist/esm/createZeroServer.native.js.map +1 -1
- package/dist/esm/createZeroServer.test.mjs +163 -0
- package/dist/esm/createZeroServer.test.mjs.map +1 -0
- package/dist/esm/createZeroServer.test.native.js +215 -0
- package/dist/esm/createZeroServer.test.native.js.map +1 -0
- package/dist/esm/httpPull/transport.test.mjs +1 -3
- package/dist/esm/httpPull/transport.test.mjs.map +1 -1
- package/dist/esm/httpPull/transport.test.native.js +2 -4
- package/dist/esm/httpPull/transport.test.native.js.map +1 -1
- package/dist/esm/mutations.mjs +2 -2
- package/dist/esm/mutations.mjs.map +1 -1
- package/dist/esm/mutations.native.js +2 -2
- package/dist/esm/mutations.native.js.map +1 -1
- package/dist/esm/provideZero.hydration.test.mjs +5 -0
- package/dist/esm/provideZero.hydration.test.mjs.map +1 -1
- package/dist/esm/provideZero.hydration.test.native.js +5 -0
- package/dist/esm/provideZero.hydration.test.native.js.map +1 -1
- package/dist/esm/testSetup.mjs +15 -0
- package/dist/esm/testSetup.mjs.map +1 -1
- package/dist/esm/testSetup.native.js +15 -0
- package/dist/esm/testSetup.native.js.map +1 -1
- package/dist/esm/useQuery.empty.test.mjs +5 -0
- package/dist/esm/useQuery.empty.test.mjs.map +1 -1
- package/dist/esm/useQuery.empty.test.native.js +5 -0
- package/dist/esm/useQuery.empty.test.native.js.map +1 -1
- package/dist/esm/zeroRunner.mjs.map +1 -1
- package/dist/esm/zeroRunner.native.js.map +1 -1
- package/package.json +2 -2
- package/src/createUseQuery.tsx +5 -2
- package/src/createUseQueryDirect.tsx +2 -2
- package/src/createZeroClient.recovery.test.tsx +104 -0
- package/src/createZeroClient.tsx +69 -51
- package/src/createZeroServer.test.ts +183 -0
- package/src/createZeroServer.ts +78 -0
- package/src/httpPull/transport.test.ts +1 -3
- package/src/mutations.ts +2 -2
- package/src/provideZero.hydration.test.tsx +10 -8
- package/src/testSetup.ts +19 -0
- package/src/useQuery.empty.test.tsx +6 -0
- package/src/zeroRunner.ts +1 -1
- package/types/createUseQuery.d.ts.map +1 -1
- package/types/createUseQueryDirect.d.ts.map +1 -1
- package/types/createZeroClient.d.ts +2 -2
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/createZeroClient.recovery.test.d.ts +5 -0
- package/types/createZeroClient.recovery.test.d.ts.map +1 -0
- package/types/createZeroServer.d.ts +2 -2
- package/types/createZeroServer.d.ts.map +1 -1
- package/types/createZeroServer.test.d.ts +2 -0
- package/types/createZeroServer.test.d.ts.map +1 -0
- package/types/testSetup.d.ts +1 -0
- package/types/useQuery.empty.test.d.ts.map +1 -1
- package/types/zeroRunner.d.ts +1 -1
- package/types/zeroRunner.d.ts.map +1 -1
- package/dist/cjs/constants.cjs +0 -31
- package/dist/cjs/constants.native.js +0 -33
- package/dist/cjs/constants.native.js.map +0 -1
- package/dist/esm/constants.mjs +0 -5
- package/dist/esm/constants.mjs.map +0 -1
- package/dist/esm/constants.native.js +0 -4
- package/dist/esm/constants.native.js.map +0 -1
- package/src/constants.native.ts +0 -2
- package/src/constants.ts +0 -9
- package/types/constants.d.ts +0 -3
- package/types/constants.d.ts.map +0 -1
- package/types/constants.native.d.ts +0 -3
- package/types/constants.native.d.ts.map +0 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { createSchema, string, table } from '@rocicorp/zero'
|
|
4
|
+
import { act } from 'react'
|
|
5
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
6
|
+
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const fakeZero = vi.hoisted(() => {
|
|
9
|
+
class FakeZero {
|
|
10
|
+
readonly context = {}
|
|
11
|
+
readonly connection = {
|
|
12
|
+
state: {
|
|
13
|
+
current: { name: 'closed' },
|
|
14
|
+
subscribe: () => () => {},
|
|
15
|
+
},
|
|
16
|
+
connect: vi.fn(),
|
|
17
|
+
}
|
|
18
|
+
readonly delete = vi.fn(async () => ({ errors: [] }))
|
|
19
|
+
readonly close = vi.fn()
|
|
20
|
+
readonly run = vi.fn(async () => [])
|
|
21
|
+
readonly preload = vi.fn(() => ({
|
|
22
|
+
cleanup: () => {},
|
|
23
|
+
complete: Promise.resolve(),
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
instances.push(this)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const instances: FakeZero[] = []
|
|
32
|
+
return { FakeZero, instances }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
vi.mock('@rocicorp/zero', async (importOriginal) => {
|
|
36
|
+
const actual = await importOriginal<typeof import('@rocicorp/zero')>()
|
|
37
|
+
return {
|
|
38
|
+
...actual,
|
|
39
|
+
Zero: fakeZero.FakeZero,
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
import { createZeroClient } from './createZeroClient'
|
|
44
|
+
|
|
45
|
+
declare global {
|
|
46
|
+
// eslint-disable-next-line no-var
|
|
47
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined
|
|
48
|
+
}
|
|
49
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
|
50
|
+
|
|
51
|
+
const noteTable = table('note').columns({ id: string(), body: string() }).primaryKey('id')
|
|
52
|
+
const schema = createSchema({ tables: [noteTable] })
|
|
53
|
+
|
|
54
|
+
const client = createZeroClient({
|
|
55
|
+
schema,
|
|
56
|
+
models: {},
|
|
57
|
+
groupedQueries: {},
|
|
58
|
+
instanceName: 'recovery-cache-test',
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
let container: HTMLDivElement
|
|
62
|
+
let root: Root | null
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
fakeZero.instances.length = 0
|
|
66
|
+
container = document.createElement('div')
|
|
67
|
+
root = null
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
if (root) act(() => root?.unmount())
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
async function mount() {
|
|
75
|
+
root = createRoot(container)
|
|
76
|
+
await act(async () => {
|
|
77
|
+
root?.render(
|
|
78
|
+
<client.ProvideZero cacheURL="http://127.0.0.1:7777/zero" userID="agentbus">
|
|
79
|
+
<span>ok</span>
|
|
80
|
+
</client.ProvideZero>,
|
|
81
|
+
)
|
|
82
|
+
await Promise.resolve()
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test('zero.delete invalidates cached instance before a remount recovery', async () => {
|
|
87
|
+
await mount()
|
|
88
|
+
const first = fakeZero.instances[0]
|
|
89
|
+
expect(first).toBeDefined()
|
|
90
|
+
|
|
91
|
+
const deleteZero = client.zero.delete
|
|
92
|
+
await act(async () => {
|
|
93
|
+
await deleteZero()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(first?.delete).toHaveBeenCalledTimes(1)
|
|
97
|
+
expect(first?.close).toHaveBeenCalledTimes(1)
|
|
98
|
+
|
|
99
|
+
act(() => root?.unmount())
|
|
100
|
+
await mount()
|
|
101
|
+
|
|
102
|
+
expect(fakeZero.instances).toHaveLength(2)
|
|
103
|
+
expect(fakeZero.instances[1]).not.toBe(first)
|
|
104
|
+
})
|
package/src/createZeroClient.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
ZeroContext,
|
|
6
6
|
ZeroProvider,
|
|
7
7
|
} from '@rocicorp/zero/react'
|
|
8
|
-
import { createEmitter, type Emitter } from '@take-out/helpers'
|
|
8
|
+
import { createEmitter, IS_SERVER_RUNTIME, type Emitter } from '@take-out/helpers'
|
|
9
9
|
import {
|
|
10
10
|
createContext,
|
|
11
11
|
memo,
|
|
@@ -264,6 +264,10 @@ export function createZeroClientInternal<
|
|
|
264
264
|
`Zero instance not initialized. Ensure ZeroProvider is mounted before accessing 'zero'.`,
|
|
265
265
|
)
|
|
266
266
|
}
|
|
267
|
+
if (key === 'delete') {
|
|
268
|
+
const instanceToDelete = latestZeroInstance
|
|
269
|
+
return () => deleteZeroInstance(instanceToDelete)
|
|
270
|
+
}
|
|
267
271
|
return getZeroProxyValue(latestZeroInstance, key)
|
|
268
272
|
},
|
|
269
273
|
})
|
|
@@ -303,7 +307,7 @@ export function createZeroClientInternal<
|
|
|
303
307
|
// here (factory-time) instead of per-call keeps hook order stable so
|
|
304
308
|
// rules-of-hooks stays happy.
|
|
305
309
|
const createUsePermission = (useQueryImpl: UseQueryHook<Schema>) => {
|
|
306
|
-
if (
|
|
310
|
+
if (IS_SERVER_RUNTIME) {
|
|
307
311
|
return (() => null) as (
|
|
308
312
|
table: TableName | (string & {}),
|
|
309
313
|
objOrId: string | Partial<Row<any>> | undefined,
|
|
@@ -373,6 +377,35 @@ export function createZeroClientInternal<
|
|
|
373
377
|
// with different identities would thrash this slot.
|
|
374
378
|
let cachedZero: { key: string; instance: ZeroInstance } | null = null
|
|
375
379
|
|
|
380
|
+
function invalidateZeroInstance(instanceToInvalidate: ZeroInstance | null): void {
|
|
381
|
+
if (!instanceToInvalidate) return
|
|
382
|
+
|
|
383
|
+
if (cachedZero?.instance === instanceToInvalidate) {
|
|
384
|
+
cachedZero = null
|
|
385
|
+
}
|
|
386
|
+
if (latestZeroInstance === instanceToInvalidate) {
|
|
387
|
+
latestZeroInstance = null
|
|
388
|
+
instance.runner = null
|
|
389
|
+
setRunner(null)
|
|
390
|
+
zeroInstanceVersion?.emit(zeroInstanceVersion.value + 1)
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
instanceToInvalidate.close()
|
|
394
|
+
} catch {
|
|
395
|
+
// a deleted client is already unusable; close is best-effort cleanup.
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function deleteZeroInstance(
|
|
400
|
+
instanceToDelete: ZeroInstance | null,
|
|
401
|
+
): Promise<unknown> {
|
|
402
|
+
try {
|
|
403
|
+
return await instanceToDelete?.delete()
|
|
404
|
+
} finally {
|
|
405
|
+
invalidateZeroInstance(instanceToDelete)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
376
409
|
// when ProvideZero is rendered without a real Zero instance (SSG, disable=true,
|
|
377
410
|
// or transiently while the active path is still creating its first instance),
|
|
378
411
|
// we want descendants' useZero() / useConnectionState() / on-zero useQuery to
|
|
@@ -401,7 +434,10 @@ export function createZeroClientInternal<
|
|
|
401
434
|
connection: { state: DISABLED_ZERO_STUB_CONNECTION_STATE },
|
|
402
435
|
} as unknown as ZeroInstance
|
|
403
436
|
|
|
404
|
-
type ProvideZeroProps = Omit<
|
|
437
|
+
type ProvideZeroProps = Omit<
|
|
438
|
+
ZeroOptions<Schema, ZeroMutators>,
|
|
439
|
+
'schema' | 'mutators'
|
|
440
|
+
> & {
|
|
405
441
|
children: ReactNode
|
|
406
442
|
authData?: AuthData | null
|
|
407
443
|
// when true, ProvideZero renders a stable shell with stub Zero — no real
|
|
@@ -425,32 +461,33 @@ export function createZeroClientInternal<
|
|
|
425
461
|
beforeReload?: () => Promise<void>
|
|
426
462
|
}
|
|
427
463
|
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
464
|
+
// providezero keeps the same provider/fiber layout on ssr and client so
|
|
465
|
+
// useId() stays stable across hydration. the server implementation has no
|
|
466
|
+
// hooks because the ssg runtime can load on-zero through a non-deduped react
|
|
467
|
+
// copy whose dispatcher is null; the client implementation always calls its
|
|
468
|
+
// hooks, regardless of `disable`.
|
|
469
|
+
const ProvideZeroServer = ({ children, authData: authDataIn }: ProvideZeroProps) => {
|
|
470
|
+
return (
|
|
471
|
+
<AuthDataContext.Provider value={(authDataIn ?? {}) as AuthData}>
|
|
472
|
+
<DisabledContext.Provider value="empty">
|
|
473
|
+
<ZeroContext.Provider value={DISABLED_ZERO_STUB as any}>
|
|
474
|
+
{/* match the active path's 3-child layout exactly so descendant
|
|
475
|
+
useId() lands at the same fiber index in both branches. the
|
|
476
|
+
two leading nulls reserve the SetZeroInstance + ConnectionMonitor
|
|
477
|
+
slots; the active path puts those components there only once
|
|
478
|
+
an instance exists. without these placeholder slots, children
|
|
479
|
+
would sit at child index 0 here but index 2 in the active
|
|
480
|
+
path, shifting every descendant useId. */}
|
|
481
|
+
{null}
|
|
482
|
+
{null}
|
|
483
|
+
{children}
|
|
484
|
+
</ZeroContext.Provider>
|
|
485
|
+
</DisabledContext.Provider>
|
|
486
|
+
</AuthDataContext.Provider>
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const ProvideZeroClient = ({
|
|
454
491
|
children,
|
|
455
492
|
authData: authDataIn,
|
|
456
493
|
transport,
|
|
@@ -459,27 +496,6 @@ export function createZeroClientInternal<
|
|
|
459
496
|
disable,
|
|
460
497
|
...props
|
|
461
498
|
}: ProvideZeroProps) => {
|
|
462
|
-
if (typeof window === 'undefined') {
|
|
463
|
-
return (
|
|
464
|
-
<AuthDataContext.Provider value={(authDataIn ?? {}) as AuthData}>
|
|
465
|
-
<DisabledContext.Provider value="empty">
|
|
466
|
-
<ZeroContext.Provider value={DISABLED_ZERO_STUB as any}>
|
|
467
|
-
{/* match the active path's 3-child layout EXACTLY so descendant
|
|
468
|
-
useId() lands at the same fiber index in both branches. the
|
|
469
|
-
two leading nulls reserve the SetZeroInstance + ConnectionMonitor
|
|
470
|
-
slots; the active path puts those components there only once
|
|
471
|
-
an instance exists. without these placeholder slots, children
|
|
472
|
-
would sit at child index 0 here but index 2 in the active
|
|
473
|
-
path, shifting every descendant useId. */}
|
|
474
|
-
{null}
|
|
475
|
-
{null}
|
|
476
|
-
{children}
|
|
477
|
-
</ZeroContext.Provider>
|
|
478
|
-
</DisabledContext.Provider>
|
|
479
|
-
</AuthDataContext.Provider>
|
|
480
|
-
)
|
|
481
|
-
}
|
|
482
|
-
|
|
483
499
|
// resolve the auth token first: a real logout (token gone) must clear
|
|
484
500
|
// authData so client mutators don't keep running as the old user, while a
|
|
485
501
|
// transient authData blip with the token still present (session refresh, tab
|
|
@@ -570,7 +586,7 @@ export function createZeroClientInternal<
|
|
|
570
586
|
// the handlers only fire post-mount).
|
|
571
587
|
const instanceRef: { current: ZeroInstance | null } = { current: null }
|
|
572
588
|
const recoveryDeps: ZeroRecoveryDeps = {
|
|
573
|
-
deleteLocalState: () => instanceRef.current
|
|
589
|
+
deleteLocalState: () => deleteZeroInstance(instanceRef.current),
|
|
574
590
|
zeroEvents,
|
|
575
591
|
beforeReload,
|
|
576
592
|
}
|
|
@@ -645,6 +661,8 @@ export function createZeroClientInternal<
|
|
|
645
661
|
)
|
|
646
662
|
}
|
|
647
663
|
|
|
664
|
+
const ProvideZero = IS_SERVER_RUNTIME ? ProvideZeroServer : ProvideZeroClient
|
|
665
|
+
|
|
648
666
|
const SetZeroInstance = () => {
|
|
649
667
|
const zeroInstance = useZero<Schema, ZeroMutators>()
|
|
650
668
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { createZeroServer } from './createZeroServer'
|
|
4
|
+
import { zeroHttpFixtureSchema } from './httpPull/fixtureSchema'
|
|
5
|
+
|
|
6
|
+
class FakePgClient {
|
|
7
|
+
lastMutationID = 0
|
|
8
|
+
pendingLastMutationID: number | undefined
|
|
9
|
+
mutationResultInserts: unknown[][] = []
|
|
10
|
+
|
|
11
|
+
async query(sql: string, params: unknown[] = []) {
|
|
12
|
+
if (sql === 'BEGIN') {
|
|
13
|
+
this.pendingLastMutationID = undefined
|
|
14
|
+
return { rows: [] }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (sql === 'COMMIT') {
|
|
18
|
+
if (this.pendingLastMutationID !== undefined) {
|
|
19
|
+
this.lastMutationID = this.pendingLastMutationID
|
|
20
|
+
}
|
|
21
|
+
this.pendingLastMutationID = undefined
|
|
22
|
+
return { rows: [] }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (sql === 'ROLLBACK') {
|
|
26
|
+
this.pendingLastMutationID = undefined
|
|
27
|
+
return { rows: [] }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (sql.includes('.clients') && sql.includes('RETURNING "lastMutationID"')) {
|
|
31
|
+
const nextMutationID = this.lastMutationID + 1
|
|
32
|
+
this.pendingLastMutationID = nextMutationID
|
|
33
|
+
return { rows: [{ lastMutationID: nextMutationID }] }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (sql.includes('information_schema.columns')) {
|
|
37
|
+
return {
|
|
38
|
+
rows: [
|
|
39
|
+
...schemaRows('user', ['id', 'name']),
|
|
40
|
+
...schemaRows('project', ['id', 'ownerId', 'name']),
|
|
41
|
+
...schemaRows('member', ['id', 'projectId', 'userId']),
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (sql.includes('INSERT INTO "zero_0"."mutations"')) {
|
|
47
|
+
this.mutationResultInserts.push(params)
|
|
48
|
+
return { rows: [] }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new Error(`unexpected fake pg query: ${sql}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function schemaRows(table: string, columns: string[]) {
|
|
56
|
+
return columns.map((column) => ({
|
|
57
|
+
schema: 'public',
|
|
58
|
+
table,
|
|
59
|
+
column,
|
|
60
|
+
dataType: 'text',
|
|
61
|
+
length: null,
|
|
62
|
+
precision: null,
|
|
63
|
+
scale: null,
|
|
64
|
+
typtype: 'b',
|
|
65
|
+
typename: 'text',
|
|
66
|
+
elemTyptype: null,
|
|
67
|
+
elemTypname: null,
|
|
68
|
+
}))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pushRequest(mutationIDs: number | number[]) {
|
|
72
|
+
const ids = Array.isArray(mutationIDs) ? mutationIDs : [mutationIDs]
|
|
73
|
+
return new Request('http://localhost/push?schema=zero_0&appID=zero', {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
clientGroupID: 'cg1',
|
|
78
|
+
mutations: ids.map((mutationID) => ({
|
|
79
|
+
type: 'custom',
|
|
80
|
+
id: mutationID,
|
|
81
|
+
clientID: 'client-a',
|
|
82
|
+
name: 'project.touch',
|
|
83
|
+
args: [{}],
|
|
84
|
+
timestamp: 1,
|
|
85
|
+
})),
|
|
86
|
+
pushVersion: 1,
|
|
87
|
+
timestamp: 1,
|
|
88
|
+
requestID: `req-${ids.join('-')}`,
|
|
89
|
+
}),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('createZeroServer mutation recovery', () => {
|
|
94
|
+
test('persists alreadyProcessed duplicate results for zero-cache recovery rows', async () => {
|
|
95
|
+
const pg = new FakePgClient()
|
|
96
|
+
const zeroServer = createZeroServer({
|
|
97
|
+
schema: zeroHttpFixtureSchema,
|
|
98
|
+
models: {
|
|
99
|
+
project: {
|
|
100
|
+
mutate: {
|
|
101
|
+
touch: async () => {},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
createServerActions: () => ({}),
|
|
106
|
+
pool: pg as any,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const first = await zeroServer.handleMutationRequest({
|
|
110
|
+
authData: null,
|
|
111
|
+
request: pushRequest(1),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
expect((first.response as any).mutations[0].result).toEqual({})
|
|
115
|
+
expect(pg.lastMutationID).toBe(1)
|
|
116
|
+
expect(pg.mutationResultInserts).toEqual([])
|
|
117
|
+
|
|
118
|
+
const duplicate = await zeroServer.handleMutationRequest({
|
|
119
|
+
authData: null,
|
|
120
|
+
request: pushRequest(1),
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const duplicateResult = (duplicate.response as any).mutations[0].result
|
|
124
|
+
expect(duplicateResult.error).toBe('alreadyProcessed')
|
|
125
|
+
expect(pg.lastMutationID).toBe(1)
|
|
126
|
+
expect(pg.mutationResultInserts).toEqual([
|
|
127
|
+
[
|
|
128
|
+
'cg1',
|
|
129
|
+
'client-a',
|
|
130
|
+
1,
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
error: 'alreadyProcessed',
|
|
133
|
+
details: duplicateResult.details,
|
|
134
|
+
}),
|
|
135
|
+
],
|
|
136
|
+
])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('persists only alreadyProcessed results in a mixed recovery push', async () => {
|
|
140
|
+
const pg = new FakePgClient()
|
|
141
|
+
const zeroServer = createZeroServer({
|
|
142
|
+
schema: zeroHttpFixtureSchema,
|
|
143
|
+
models: {
|
|
144
|
+
project: {
|
|
145
|
+
mutate: {
|
|
146
|
+
touch: async () => {},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
createServerActions: () => ({}),
|
|
151
|
+
pool: pg as any,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
await zeroServer.handleMutationRequest({
|
|
155
|
+
authData: null,
|
|
156
|
+
request: pushRequest(1),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const mixed = await zeroServer.handleMutationRequest({
|
|
160
|
+
authData: null,
|
|
161
|
+
request: pushRequest([1, 2]),
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const results = (mixed.response as any).mutations.map((mutation: any) => ({
|
|
165
|
+
id: mutation.id.id,
|
|
166
|
+
result: mutation.result,
|
|
167
|
+
}))
|
|
168
|
+
expect(results[0].result.error).toBe('alreadyProcessed')
|
|
169
|
+
expect(results[1]).toEqual({ id: 2, result: {} })
|
|
170
|
+
expect(pg.lastMutationID).toBe(2)
|
|
171
|
+
expect(pg.mutationResultInserts).toEqual([
|
|
172
|
+
[
|
|
173
|
+
'cg1',
|
|
174
|
+
'client-a',
|
|
175
|
+
1,
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
error: 'alreadyProcessed',
|
|
178
|
+
details: results[0].result.details,
|
|
179
|
+
}),
|
|
180
|
+
],
|
|
181
|
+
])
|
|
182
|
+
})
|
|
183
|
+
})
|
package/src/createZeroServer.ts
CHANGED
|
@@ -44,6 +44,21 @@ type MutateOptions = {
|
|
|
44
44
|
awaitEffects?: boolean
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
type AlreadyProcessedMutation = {
|
|
48
|
+
id: {
|
|
49
|
+
clientID: string
|
|
50
|
+
id: number
|
|
51
|
+
}
|
|
52
|
+
result: {
|
|
53
|
+
error: 'alreadyProcessed'
|
|
54
|
+
details?: unknown
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type PushRequestBody = {
|
|
59
|
+
clientGroupID?: unknown
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
type ServerMutate<Models extends GenericModels> = {
|
|
48
63
|
[Key in keyof Models]: {
|
|
49
64
|
[K in keyof Models[Key]['mutate']]: Models[Key]['mutate'][K] extends (
|
|
@@ -179,6 +194,7 @@ export function createZeroServer<
|
|
|
179
194
|
}) => {
|
|
180
195
|
// since mutations do DB work in transaction, avoid any async tasks during
|
|
181
196
|
const asyncTasks: AsyncAction[] = []
|
|
197
|
+
const recoveryRequest = request.clone()
|
|
182
198
|
|
|
183
199
|
const mutators = createMutators({
|
|
184
200
|
asyncTasks,
|
|
@@ -193,6 +209,7 @@ export function createZeroServer<
|
|
|
193
209
|
|
|
194
210
|
// @ts-expect-error type is ok but config in monorepo
|
|
195
211
|
const response = await processor.process(mutators, request)
|
|
212
|
+
await persistAlreadyProcessedMutationResults(pool, recoveryRequest, response)
|
|
196
213
|
|
|
197
214
|
// now finish
|
|
198
215
|
if (!skipAsyncTasks && asyncTasks.length) {
|
|
@@ -416,3 +433,64 @@ export function createZeroServer<
|
|
|
416
433
|
query,
|
|
417
434
|
}
|
|
418
435
|
}
|
|
436
|
+
|
|
437
|
+
function alreadyProcessedMutations(response: unknown): AlreadyProcessedMutation[] {
|
|
438
|
+
if (!response || typeof response !== 'object') return []
|
|
439
|
+
const mutations = (response as { mutations?: unknown }).mutations
|
|
440
|
+
if (!Array.isArray(mutations)) return []
|
|
441
|
+
|
|
442
|
+
return mutations.filter((mutation): mutation is AlreadyProcessedMutation => {
|
|
443
|
+
if (!mutation || typeof mutation !== 'object') return false
|
|
444
|
+
const id = (mutation as { id?: unknown }).id
|
|
445
|
+
const result = (mutation as { result?: unknown }).result
|
|
446
|
+
return (
|
|
447
|
+
!!id &&
|
|
448
|
+
typeof id === 'object' &&
|
|
449
|
+
typeof (id as { clientID?: unknown }).clientID === 'string' &&
|
|
450
|
+
typeof (id as { id?: unknown }).id === 'number' &&
|
|
451
|
+
!!result &&
|
|
452
|
+
typeof result === 'object' &&
|
|
453
|
+
(result as { error?: unknown }).error === 'alreadyProcessed'
|
|
454
|
+
)
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function persistAlreadyProcessedMutationResults(
|
|
459
|
+
pool: NodePgTransaction,
|
|
460
|
+
request: Request,
|
|
461
|
+
response: unknown,
|
|
462
|
+
) {
|
|
463
|
+
const mutations = alreadyProcessedMutations(response)
|
|
464
|
+
if (mutations.length === 0) return
|
|
465
|
+
|
|
466
|
+
const schema = new URL(request.url).searchParams.get('schema')
|
|
467
|
+
if (!schema) return
|
|
468
|
+
|
|
469
|
+
const body = (await request.json()) as PushRequestBody
|
|
470
|
+
if (typeof body.clientGroupID !== 'string') return
|
|
471
|
+
|
|
472
|
+
const table = `${quotePgIdentifier(schema)}."mutations"`
|
|
473
|
+
const values = mutations
|
|
474
|
+
.map((_, index) => {
|
|
475
|
+
const offset = index * 4
|
|
476
|
+
return `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}::json)`
|
|
477
|
+
})
|
|
478
|
+
.join(', ')
|
|
479
|
+
const params = mutations.flatMap((mutation) => [
|
|
480
|
+
body.clientGroupID,
|
|
481
|
+
mutation.id.clientID,
|
|
482
|
+
mutation.id.id,
|
|
483
|
+
JSON.stringify(mutation.result),
|
|
484
|
+
])
|
|
485
|
+
|
|
486
|
+
await pool.query(
|
|
487
|
+
`INSERT INTO ${table} ("clientGroupID", "clientID", "mutationID", "result")
|
|
488
|
+
VALUES ${values}
|
|
489
|
+
ON CONFLICT ("clientGroupID", "clientID", "mutationID") DO NOTHING`,
|
|
490
|
+
params,
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function quotePgIdentifier(value: string) {
|
|
495
|
+
return `"${value.replaceAll('"', '""')}"`
|
|
496
|
+
}
|
|
@@ -480,9 +480,7 @@ describe('zero-http transport', () => {
|
|
|
480
480
|
install(fetch)
|
|
481
481
|
const onClientStateNotFound = vi.fn()
|
|
482
482
|
createZero({ onClientStateNotFound })
|
|
483
|
-
await
|
|
484
|
-
timeout: 5_000,
|
|
485
|
-
})
|
|
483
|
+
await eventually(() => expect(onClientStateNotFound).toHaveBeenCalled(), 5_000)
|
|
486
484
|
})
|
|
487
485
|
})
|
|
488
486
|
|
package/src/mutations.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { IS_SERVER_RUNTIME } from '@take-out/helpers'
|
|
2
2
|
import { getDidRunPermissionCheck } from './helpers/didRunPermissionCheck'
|
|
3
3
|
import { setMutationsPermissions } from './modelRegistry'
|
|
4
4
|
|
|
@@ -170,7 +170,7 @@ export function mutations<
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
// only validate on the server
|
|
173
|
-
if (
|
|
173
|
+
if (IS_SERVER_RUNTIME) {
|
|
174
174
|
await ctx.can(permissions, obj)
|
|
175
175
|
}
|
|
176
176
|
}
|
|
@@ -17,13 +17,16 @@
|
|
|
17
17
|
// stability is what keeps useId stable.
|
|
18
18
|
|
|
19
19
|
import { createSchema, string, table } from '@rocicorp/zero'
|
|
20
|
+
import { IS_SERVER, IS_SERVER_RUNTIME } from '@take-out/helpers'
|
|
20
21
|
import { useId, type ReactNode } from 'react'
|
|
21
22
|
import { renderToString } from 'react-dom/server'
|
|
22
23
|
import { expect, test } from 'vitest'
|
|
23
24
|
|
|
24
25
|
import { createZeroClient } from './createZeroClient'
|
|
25
26
|
|
|
26
|
-
const todoTable = table('todo')
|
|
27
|
+
const todoTable = table('todo')
|
|
28
|
+
.columns({ id: string(), title: string() })
|
|
29
|
+
.primaryKey('id')
|
|
27
30
|
const schema = createSchema({ tables: [todoTable] })
|
|
28
31
|
|
|
29
32
|
const client = createZeroClient({
|
|
@@ -38,13 +41,7 @@ function IdProbe({ name }: { name: string }) {
|
|
|
38
41
|
return <span data-name={name} data-probe-id={id} />
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
function Tree({
|
|
42
|
-
children,
|
|
43
|
-
disable,
|
|
44
|
-
}: {
|
|
45
|
-
children?: ReactNode
|
|
46
|
-
disable?: boolean
|
|
47
|
-
}) {
|
|
44
|
+
function Tree({ children, disable }: { children?: ReactNode; disable?: boolean }) {
|
|
48
45
|
return (
|
|
49
46
|
<client.ProvideZero authData={{}} userID="anon" disable={disable}>
|
|
50
47
|
<IdProbe name="a" />
|
|
@@ -69,6 +66,11 @@ function readIds(html: string): Record<string, string> {
|
|
|
69
66
|
return out
|
|
70
67
|
}
|
|
71
68
|
|
|
69
|
+
test('test environment loads on-zero client runtime', () => {
|
|
70
|
+
expect(IS_SERVER_RUNTIME).toBe(false)
|
|
71
|
+
expect(IS_SERVER).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
72
74
|
test('useId is stable across active vs disabled ProvideZero renders', () => {
|
|
73
75
|
const active = readIds(renderToString(<Tree />))
|
|
74
76
|
const disabled = readIds(renderToString(<Tree disable />))
|
package/src/testSetup.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
import { afterAll, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const isJsdomRuntime = typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
4
|
+
const previousViteEnvironment = process.env.VITE_ENVIRONMENT
|
|
5
|
+
|
|
6
|
+
if (isJsdomRuntime) {
|
|
7
|
+
process.env.VITE_ENVIRONMENT = 'client'
|
|
8
|
+
vi.resetModules()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
afterAll(() => {
|
|
12
|
+
if (!isJsdomRuntime) return
|
|
13
|
+
if (previousViteEnvironment === undefined) {
|
|
14
|
+
delete process.env.VITE_ENVIRONMENT
|
|
15
|
+
} else {
|
|
16
|
+
process.env.VITE_ENVIRONMENT = previousViteEnvironment
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
1
20
|
if (typeof globalThis.localStorage?.getItem !== 'function') {
|
|
2
21
|
const store = new Map<string, string>()
|
|
3
22
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// queries get `[undefined, info]` instead (the established zero-react shape).
|
|
8
8
|
|
|
9
9
|
import { createSchema, number, string, table } from '@rocicorp/zero'
|
|
10
|
+
import { IS_SERVER, IS_SERVER_RUNTIME } from '@take-out/helpers'
|
|
10
11
|
import { act } from 'react'
|
|
11
12
|
import { createRoot, type Root } from 'react-dom/client'
|
|
12
13
|
import { afterEach, beforeEach, expect, test } from 'vitest'
|
|
@@ -78,6 +79,11 @@ function renderWithDisabled<T>(useHook: () => T): T {
|
|
|
78
79
|
return captured
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
test('test environment loads on-zero client runtime', () => {
|
|
83
|
+
expect(IS_SERVER_RUNTIME).toBe(false)
|
|
84
|
+
expect(IS_SERVER).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
81
87
|
// regression: previously this returned [null, ...] which crashed downstream
|
|
82
88
|
// .filter / .find / .length. now it must return [[], ...] for plural queries.
|
|
83
89
|
test('useQuery returns [] (not null) for plural queries under DisabledContext', () => {
|