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.
Files changed (119) hide show
  1. package/dist/cjs/createUseQuery.cjs +2 -1
  2. package/dist/cjs/createUseQuery.native.js +2 -1
  3. package/dist/cjs/createUseQuery.native.js.map +1 -1
  4. package/dist/cjs/createUseQueryDirect.cjs +1 -1
  5. package/dist/cjs/createUseQueryDirect.native.js +1 -1
  6. package/dist/cjs/createUseQueryDirect.native.js.map +1 -1
  7. package/dist/cjs/createZeroClient.cjs +45 -15
  8. package/dist/cjs/createZeroClient.native.js +56 -25
  9. package/dist/cjs/createZeroClient.native.js.map +1 -1
  10. package/dist/cjs/createZeroClient.recovery.test.cjs +96 -0
  11. package/dist/cjs/createZeroClient.recovery.test.native.js +130 -0
  12. package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -0
  13. package/dist/cjs/createZeroServer.cjs +33 -0
  14. package/dist/cjs/createZeroServer.native.js +40 -0
  15. package/dist/cjs/createZeroServer.native.js.map +1 -1
  16. package/dist/cjs/createZeroServer.test.cjs +162 -0
  17. package/dist/cjs/createZeroServer.test.native.js +217 -0
  18. package/dist/cjs/createZeroServer.test.native.js.map +1 -0
  19. package/dist/cjs/httpPull/transport.test.cjs +1 -3
  20. package/dist/cjs/httpPull/transport.test.native.js +2 -4
  21. package/dist/cjs/httpPull/transport.test.native.js.map +1 -1
  22. package/dist/cjs/mutations.cjs +2 -2
  23. package/dist/cjs/mutations.native.js +2 -2
  24. package/dist/cjs/mutations.native.js.map +1 -1
  25. package/dist/cjs/provideZero.hydration.test.cjs +5 -0
  26. package/dist/cjs/provideZero.hydration.test.native.js +5 -0
  27. package/dist/cjs/provideZero.hydration.test.native.js.map +1 -1
  28. package/dist/cjs/testSetup.cjs +15 -0
  29. package/dist/cjs/testSetup.native.js +15 -0
  30. package/dist/cjs/testSetup.native.js.map +1 -1
  31. package/dist/cjs/useQuery.empty.test.cjs +5 -0
  32. package/dist/cjs/useQuery.empty.test.native.js +5 -0
  33. package/dist/cjs/useQuery.empty.test.native.js.map +1 -1
  34. package/dist/esm/createUseQuery.mjs +2 -1
  35. package/dist/esm/createUseQuery.mjs.map +1 -1
  36. package/dist/esm/createUseQuery.native.js +2 -1
  37. package/dist/esm/createUseQuery.native.js.map +1 -1
  38. package/dist/esm/createUseQueryDirect.mjs +2 -2
  39. package/dist/esm/createUseQueryDirect.mjs.map +1 -1
  40. package/dist/esm/createUseQueryDirect.native.js +2 -2
  41. package/dist/esm/createUseQueryDirect.native.js.map +1 -1
  42. package/dist/esm/createZeroClient.mjs +46 -16
  43. package/dist/esm/createZeroClient.mjs.map +1 -1
  44. package/dist/esm/createZeroClient.native.js +57 -26
  45. package/dist/esm/createZeroClient.native.js.map +1 -1
  46. package/dist/esm/createZeroClient.recovery.test.mjs +97 -0
  47. package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -0
  48. package/dist/esm/createZeroClient.recovery.test.native.js +128 -0
  49. package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -0
  50. package/dist/esm/createZeroServer.mjs +33 -0
  51. package/dist/esm/createZeroServer.mjs.map +1 -1
  52. package/dist/esm/createZeroServer.native.js +40 -0
  53. package/dist/esm/createZeroServer.native.js.map +1 -1
  54. package/dist/esm/createZeroServer.test.mjs +163 -0
  55. package/dist/esm/createZeroServer.test.mjs.map +1 -0
  56. package/dist/esm/createZeroServer.test.native.js +215 -0
  57. package/dist/esm/createZeroServer.test.native.js.map +1 -0
  58. package/dist/esm/httpPull/transport.test.mjs +1 -3
  59. package/dist/esm/httpPull/transport.test.mjs.map +1 -1
  60. package/dist/esm/httpPull/transport.test.native.js +2 -4
  61. package/dist/esm/httpPull/transport.test.native.js.map +1 -1
  62. package/dist/esm/mutations.mjs +2 -2
  63. package/dist/esm/mutations.mjs.map +1 -1
  64. package/dist/esm/mutations.native.js +2 -2
  65. package/dist/esm/mutations.native.js.map +1 -1
  66. package/dist/esm/provideZero.hydration.test.mjs +5 -0
  67. package/dist/esm/provideZero.hydration.test.mjs.map +1 -1
  68. package/dist/esm/provideZero.hydration.test.native.js +5 -0
  69. package/dist/esm/provideZero.hydration.test.native.js.map +1 -1
  70. package/dist/esm/testSetup.mjs +15 -0
  71. package/dist/esm/testSetup.mjs.map +1 -1
  72. package/dist/esm/testSetup.native.js +15 -0
  73. package/dist/esm/testSetup.native.js.map +1 -1
  74. package/dist/esm/useQuery.empty.test.mjs +5 -0
  75. package/dist/esm/useQuery.empty.test.mjs.map +1 -1
  76. package/dist/esm/useQuery.empty.test.native.js +5 -0
  77. package/dist/esm/useQuery.empty.test.native.js.map +1 -1
  78. package/dist/esm/zeroRunner.mjs.map +1 -1
  79. package/dist/esm/zeroRunner.native.js.map +1 -1
  80. package/package.json +2 -2
  81. package/src/createUseQuery.tsx +5 -2
  82. package/src/createUseQueryDirect.tsx +2 -2
  83. package/src/createZeroClient.recovery.test.tsx +104 -0
  84. package/src/createZeroClient.tsx +69 -51
  85. package/src/createZeroServer.test.ts +183 -0
  86. package/src/createZeroServer.ts +78 -0
  87. package/src/httpPull/transport.test.ts +1 -3
  88. package/src/mutations.ts +2 -2
  89. package/src/provideZero.hydration.test.tsx +10 -8
  90. package/src/testSetup.ts +19 -0
  91. package/src/useQuery.empty.test.tsx +6 -0
  92. package/src/zeroRunner.ts +1 -1
  93. package/types/createUseQuery.d.ts.map +1 -1
  94. package/types/createUseQueryDirect.d.ts.map +1 -1
  95. package/types/createZeroClient.d.ts +2 -2
  96. package/types/createZeroClient.d.ts.map +1 -1
  97. package/types/createZeroClient.recovery.test.d.ts +5 -0
  98. package/types/createZeroClient.recovery.test.d.ts.map +1 -0
  99. package/types/createZeroServer.d.ts +2 -2
  100. package/types/createZeroServer.d.ts.map +1 -1
  101. package/types/createZeroServer.test.d.ts +2 -0
  102. package/types/createZeroServer.test.d.ts.map +1 -0
  103. package/types/testSetup.d.ts +1 -0
  104. package/types/useQuery.empty.test.d.ts.map +1 -1
  105. package/types/zeroRunner.d.ts +1 -1
  106. package/types/zeroRunner.d.ts.map +1 -1
  107. package/dist/cjs/constants.cjs +0 -31
  108. package/dist/cjs/constants.native.js +0 -33
  109. package/dist/cjs/constants.native.js.map +0 -1
  110. package/dist/esm/constants.mjs +0 -5
  111. package/dist/esm/constants.mjs.map +0 -1
  112. package/dist/esm/constants.native.js +0 -4
  113. package/dist/esm/constants.native.js.map +0 -1
  114. package/src/constants.native.ts +0 -2
  115. package/src/constants.ts +0 -9
  116. package/types/constants.d.ts +0 -3
  117. package/types/constants.d.ts.map +0 -1
  118. package/types/constants.native.d.ts +0 -3
  119. 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
+ })
@@ -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 (typeof window === 'undefined') {
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<ZeroOptions<Schema, ZeroMutators>, 'schema' | 'mutators'> & {
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
- // ProvideZero is ONE component (no inner ProvideZeroActive wrapper). a
429
- // single component shape on both SSR and client means React's useId() —
430
- // which keys on the consuming fiber's tree position produces identical
431
- // ids on both sides for every descendant. an extra wrapper fiber (the
432
- // previous shape: SSR returned the 3 providers directly while the client
433
- // wrapped them in a ProvideZeroActive intermediate) shifted every
434
- // descendant's tree id by a step and broke hydration on every useId
435
- // consumer (SVG ids, form ids, etc).
436
- //
437
- // SSR path: EARLY-RETURN before any hook call. on-zero's hooks
438
- // (useRef/useState/useEffect) call through a React copy whose hook
439
- // dispatcher is null on the SSG runtime (because react isn't deduplicated
440
- // across the build's bundle for on-zero's slot), so any hook call here
441
- // throws "Cannot read properties of null (reading 'useRef')". the SSR
442
- // shell renders the same 3-provider 3-child shape as the client.
443
- //
444
- // CLIENT path: hooks ALWAYS run, regardless of `disable`. only the
445
- // returned JSX changes when `disable` flips — so a caller can toggle
446
- // active ↔ inert at runtime without violating rules-of-hooks (no
447
- // remount, no key-rotation needed). this is the structural guarantee
448
- // consumers like soot's ProjectZeroGate depend on to flip from
449
- // disable=true (anon-ssr hydration first render) to disable=false
450
- // (active project mounted) without re-parenting children — children
451
- // stay at the same fiber position whether the wrapper is inert or
452
- // active.
453
- const ProvideZero = ({
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?.delete() ?? Promise.resolve(),
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
+ })
@@ -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 vi.waitFor(() => expect(onClientStateNotFound).toHaveBeenCalled(), {
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 { isServer } from './constants'
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 (isServer) {
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').columns({ id: string(), title: string() }).primaryKey('id')
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', () => {