on-zero 0.4.26 → 0.4.27

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 (123) hide show
  1. package/dist/cjs/createZeroClient.cjs +13 -1
  2. package/dist/cjs/createZeroClient.native.js +13 -1
  3. package/dist/cjs/createZeroClient.native.js.map +1 -1
  4. package/dist/cjs/httpPull/auth.test.cjs +197 -0
  5. package/dist/cjs/httpPull/auth.test.native.js +279 -0
  6. package/dist/cjs/httpPull/auth.test.native.js.map +1 -0
  7. package/dist/cjs/httpPull/churn.test.cjs +132 -0
  8. package/dist/cjs/httpPull/churn.test.native.js +155 -0
  9. package/dist/cjs/httpPull/churn.test.native.js.map +1 -0
  10. package/dist/cjs/httpPull/fixtureSchema.cjs +76 -0
  11. package/dist/cjs/httpPull/fixtureSchema.native.js +82 -0
  12. package/dist/cjs/httpPull/fixtureSchema.native.js.map +1 -0
  13. package/dist/cjs/httpPull/fixtureServer.cjs +340 -0
  14. package/dist/cjs/httpPull/fixtureServer.native.js +534 -0
  15. package/dist/cjs/httpPull/fixtureServer.native.js.map +1 -0
  16. package/dist/cjs/httpPull/integration.test.cjs +53 -0
  17. package/dist/cjs/httpPull/integration.test.native.js +60 -0
  18. package/dist/cjs/httpPull/integration.test.native.js.map +1 -0
  19. package/dist/cjs/httpPull/rebase.test.cjs +360 -0
  20. package/dist/cjs/httpPull/rebase.test.native.js +420 -0
  21. package/dist/cjs/httpPull/rebase.test.native.js.map +1 -0
  22. package/dist/cjs/httpPull/relations.test.cjs +107 -0
  23. package/dist/cjs/httpPull/relations.test.native.js +119 -0
  24. package/dist/cjs/httpPull/relations.test.native.js.map +1 -0
  25. package/dist/cjs/httpPull/testHarness.cjs +100 -0
  26. package/dist/cjs/httpPull/testHarness.native.js +112 -0
  27. package/dist/cjs/httpPull/testHarness.native.js.map +1 -0
  28. package/dist/cjs/httpPull/transport.test.cjs +568 -0
  29. package/dist/cjs/httpPull/transport.test.native.js +655 -0
  30. package/dist/cjs/httpPull/transport.test.native.js.map +1 -0
  31. package/dist/cjs/httpPullTransport.cjs +432 -0
  32. package/dist/cjs/httpPullTransport.native.js +695 -0
  33. package/dist/cjs/httpPullTransport.native.js.map +1 -0
  34. package/dist/cjs/index.cjs +1 -0
  35. package/dist/cjs/index.native.js +1 -0
  36. package/dist/cjs/index.native.js.map +1 -1
  37. package/dist/esm/createZeroClient.mjs +13 -1
  38. package/dist/esm/createZeroClient.mjs.map +1 -1
  39. package/dist/esm/createZeroClient.native.js +13 -1
  40. package/dist/esm/createZeroClient.native.js.map +1 -1
  41. package/dist/esm/httpPull/auth.test.mjs +198 -0
  42. package/dist/esm/httpPull/auth.test.mjs.map +1 -0
  43. package/dist/esm/httpPull/auth.test.native.js +277 -0
  44. package/dist/esm/httpPull/auth.test.native.js.map +1 -0
  45. package/dist/esm/httpPull/churn.test.mjs +133 -0
  46. package/dist/esm/httpPull/churn.test.mjs.map +1 -0
  47. package/dist/esm/httpPull/churn.test.native.js +153 -0
  48. package/dist/esm/httpPull/churn.test.native.js.map +1 -0
  49. package/dist/esm/httpPull/fixtureSchema.mjs +50 -0
  50. package/dist/esm/httpPull/fixtureSchema.mjs.map +1 -0
  51. package/dist/esm/httpPull/fixtureSchema.native.js +53 -0
  52. package/dist/esm/httpPull/fixtureSchema.native.js.map +1 -0
  53. package/dist/esm/httpPull/fixtureServer.mjs +315 -0
  54. package/dist/esm/httpPull/fixtureServer.mjs.map +1 -0
  55. package/dist/esm/httpPull/fixtureServer.native.js +506 -0
  56. package/dist/esm/httpPull/fixtureServer.native.js.map +1 -0
  57. package/dist/esm/httpPull/integration.test.mjs +54 -0
  58. package/dist/esm/httpPull/integration.test.mjs.map +1 -0
  59. package/dist/esm/httpPull/integration.test.native.js +58 -0
  60. package/dist/esm/httpPull/integration.test.native.js.map +1 -0
  61. package/dist/esm/httpPull/rebase.test.mjs +361 -0
  62. package/dist/esm/httpPull/rebase.test.mjs.map +1 -0
  63. package/dist/esm/httpPull/rebase.test.native.js +418 -0
  64. package/dist/esm/httpPull/rebase.test.native.js.map +1 -0
  65. package/dist/esm/httpPull/relations.test.mjs +108 -0
  66. package/dist/esm/httpPull/relations.test.mjs.map +1 -0
  67. package/dist/esm/httpPull/relations.test.native.js +117 -0
  68. package/dist/esm/httpPull/relations.test.native.js.map +1 -0
  69. package/dist/esm/httpPull/testHarness.mjs +72 -0
  70. package/dist/esm/httpPull/testHarness.mjs.map +1 -0
  71. package/dist/esm/httpPull/testHarness.native.js +81 -0
  72. package/dist/esm/httpPull/testHarness.native.js.map +1 -0
  73. package/dist/esm/httpPull/transport.test.mjs +569 -0
  74. package/dist/esm/httpPull/transport.test.mjs.map +1 -0
  75. package/dist/esm/httpPull/transport.test.native.js +653 -0
  76. package/dist/esm/httpPull/transport.test.native.js.map +1 -0
  77. package/dist/esm/httpPullTransport.mjs +406 -0
  78. package/dist/esm/httpPullTransport.mjs.map +1 -0
  79. package/dist/esm/httpPullTransport.native.js +666 -0
  80. package/dist/esm/httpPullTransport.native.js.map +1 -0
  81. package/dist/esm/index.js +1 -0
  82. package/dist/esm/index.js.map +1 -1
  83. package/dist/esm/index.mjs +1 -0
  84. package/dist/esm/index.mjs.map +1 -1
  85. package/dist/esm/index.native.js +1 -0
  86. package/dist/esm/index.native.js.map +1 -1
  87. package/package.json +2 -2
  88. package/src/createZeroClient.tsx +19 -0
  89. package/src/httpPull/auth.test.ts +208 -0
  90. package/src/httpPull/churn.test.ts +147 -0
  91. package/src/httpPull/fixtureSchema.ts +82 -0
  92. package/src/httpPull/fixtureServer.ts +391 -0
  93. package/src/httpPull/integration.test.ts +57 -0
  94. package/src/httpPull/rebase.test.ts +368 -0
  95. package/src/httpPull/relations.test.ts +135 -0
  96. package/src/httpPull/testHarness.ts +95 -0
  97. package/src/httpPull/transport.test.ts +577 -0
  98. package/src/httpPullTransport.ts +559 -0
  99. package/src/index.ts +1 -0
  100. package/types/createZeroClient.d.ts +3 -1
  101. package/types/createZeroClient.d.ts.map +1 -1
  102. package/types/httpPull/auth.test.d.ts +2 -0
  103. package/types/httpPull/auth.test.d.ts.map +1 -0
  104. package/types/httpPull/churn.test.d.ts +2 -0
  105. package/types/httpPull/churn.test.d.ts.map +1 -0
  106. package/types/httpPull/fixtureSchema.d.ts +111 -0
  107. package/types/httpPull/fixtureSchema.d.ts.map +1 -0
  108. package/types/httpPull/fixtureServer.d.ts +14 -0
  109. package/types/httpPull/fixtureServer.d.ts.map +1 -0
  110. package/types/httpPull/integration.test.d.ts +2 -0
  111. package/types/httpPull/integration.test.d.ts.map +1 -0
  112. package/types/httpPull/rebase.test.d.ts +2 -0
  113. package/types/httpPull/rebase.test.d.ts.map +1 -0
  114. package/types/httpPull/relations.test.d.ts +2 -0
  115. package/types/httpPull/relations.test.d.ts.map +1 -0
  116. package/types/httpPull/testHarness.d.ts +32 -0
  117. package/types/httpPull/testHarness.d.ts.map +1 -0
  118. package/types/httpPull/transport.test.d.ts +2 -0
  119. package/types/httpPull/transport.test.d.ts.map +1 -0
  120. package/types/httpPullTransport.d.ts +13 -0
  121. package/types/httpPullTransport.d.ts.map +1 -0
  122. package/types/index.d.ts +1 -0
  123. package/types/index.d.ts.map +1 -1
package/dist/esm/index.js CHANGED
@@ -9,6 +9,7 @@ import { ensureAuth, getAuth } from "./helpers/getAuth.mjs";
9
9
  import { setAuthData, setEnvironment } from "./state.mjs";
10
10
  export * from "./combineZeroClients.mjs";
11
11
  export * from "./createZeroClient.mjs";
12
+ export * from "./httpPullTransport.mjs";
12
13
  export * from "./createUseQuery.mjs";
13
14
  export * from "./resolveQuery.mjs";
14
15
  export * from "./run.mjs";
@@ -1 +1 @@
1
- {"version":3,"names":["ensureAuth","getAuth","setAuthData","setEnvironment","setRunner","defineQuery","defineQueries","clearZeroClientData","showZeroClientErrorOnce","resetShownZeroClientError"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASA,UAAA,EAAYC,OAAA,QAAe;AACpC,SAASC,WAAA,EAAaC,cAAA,QAAsB;AAE5C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,SAAA,QAAkC;AAC3C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,WAAA,EAAaC,aAAA,QAAqB;AAQ3C,SACEC,mBAAA,QAEK;AACP,SACEC,uBAAA,EACAC,yBAAA,QAGK","ignoreList":[]}
1
+ {"version":3,"names":["ensureAuth","getAuth","setAuthData","setEnvironment","setRunner","defineQuery","defineQueries","clearZeroClientData","showZeroClientErrorOnce","resetShownZeroClientError"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASA,UAAA,EAAYC,OAAA,QAAe;AACpC,SAASC,WAAA,EAAaC,cAAA,QAAsB;AAE5C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,SAAA,QAAkC;AAC3C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,WAAA,EAAaC,aAAA,QAAqB;AAQ3C,SACEC,mBAAA,QAEK;AACP,SACEC,uBAAA,EACAC,yBAAA,QAGK","ignoreList":[]}
@@ -9,6 +9,7 @@ import { ensureAuth, getAuth } from "./helpers/getAuth.mjs";
9
9
  import { setAuthData, setEnvironment } from "./state.mjs";
10
10
  export * from "./combineZeroClients.mjs";
11
11
  export * from "./createZeroClient.mjs";
12
+ export * from "./httpPullTransport.mjs";
12
13
  export * from "./createUseQuery.mjs";
13
14
  export * from "./resolveQuery.mjs";
14
15
  export * from "./run.mjs";
@@ -1 +1 @@
1
- {"version":3,"names":["ensureAuth","getAuth","setAuthData","setEnvironment","setRunner","defineQuery","defineQueries","clearZeroClientData","showZeroClientErrorOnce","resetShownZeroClientError"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASA,UAAA,EAAYC,OAAA,QAAe;AACpC,SAASC,WAAA,EAAaC,cAAA,QAAsB;AAE5C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,SAAA,QAAkC;AAC3C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,WAAA,EAAaC,aAAA,QAAqB;AAQ3C,SACEC,mBAAA,QAEK;AACP,SACEC,uBAAA,EACAC,yBAAA,QAGK","ignoreList":[]}
1
+ {"version":3,"names":["ensureAuth","getAuth","setAuthData","setEnvironment","setRunner","defineQuery","defineQueries","clearZeroClientData","showZeroClientErrorOnce","resetShownZeroClientError"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASA,UAAA,EAAYC,OAAA,QAAe;AACpC,SAASC,WAAA,EAAaC,cAAA,QAAsB;AAE5C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,SAAA,QAAkC;AAC3C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,WAAA,EAAaC,aAAA,QAAqB;AAQ3C,SACEC,mBAAA,QAEK;AACP,SACEC,uBAAA,EACAC,yBAAA,QAGK","ignoreList":[]}
@@ -9,6 +9,7 @@ import { ensureAuth, getAuth } from "./helpers/getAuth.native.js";
9
9
  import { setAuthData, setEnvironment } from "./state.native.js";
10
10
  export * from "./combineZeroClients.native.js";
11
11
  export * from "./createZeroClient.native.js";
12
+ export * from "./httpPullTransport.native.js";
12
13
  export * from "./createUseQuery.native.js";
13
14
  export * from "./resolveQuery.native.js";
14
15
  export * from "./run.native.js";
@@ -1 +1 @@
1
- {"version":3,"names":["ensureAuth","getAuth","setAuthData","setEnvironment","setRunner","defineQuery","defineQueries","clearZeroClientData","showZeroClientErrorOnce","resetShownZeroClientError"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASA,UAAA,EAAYC,OAAA,QAAe;AACpC,SAASC,WAAA,EAAaC,cAAA,QAAsB;AAE5C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,SAAA,QAAkC;AAC3C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,WAAA,EAAaC,aAAA,QAAqB;AAQ3C,SAAAC,mBAAA;AAAA,SACEC,uBAAA,EAAAC,yBAAA;AAAA,SAGFF,mBAAA,EACED,aAAA,EACAD,WAAA,EAAAL,UAGK,E","ignoreList":[]}
1
+ {"version":3,"names":["ensureAuth","getAuth","setAuthData","setEnvironment","setRunner","defineQuery","defineQueries","clearZeroClientData","showZeroClientErrorOnce","resetShownZeroClientError"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASA,UAAA,EAAYC,OAAA,QAAe;AACpC,SAASC,WAAA,EAAaC,cAAA,QAAsB;AAE5C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,SAAA,QAAkC;AAC3C,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,SAASC,WAAA,EAAaC,aAAA,QAAqB;AAQ3C,SAAAC,mBAAA;AAAA,SACEC,uBAAA,EAAAC,yBAAA;AAAA,SAGFF,mBAAA,EACED,aAAA,EACAD,WAAA,EAAAL,UAGK,E","ignoreList":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "on-zero",
3
- "version": "0.4.26",
3
+ "version": "0.4.27",
4
4
  "description": "A typed layer over @rocicorp/zero with queries, mutations, and permissions",
5
5
  "sideEffects": false,
6
6
  "source": "src/index.ts",
@@ -77,7 +77,7 @@
77
77
  }
78
78
  },
79
79
  "dependencies": {
80
- "@take-out/helpers": "0.4.26",
80
+ "@take-out/helpers": "0.4.27",
81
81
  "chokidar": "^4.0.3",
82
82
  "citty": "^0.1.6",
83
83
  "valibot": "^1.1.0"
@@ -13,6 +13,7 @@ import {
13
13
  } from 'react'
14
14
 
15
15
  import { createPermissions } from './createPermissions'
16
+ import { ensureHttpPullTransport } from './httpPullTransport'
16
17
  import {
17
18
  createUseQuery,
18
19
  createUseQueryDirect,
@@ -331,11 +332,20 @@ export function createZeroClient<
331
332
  children,
332
333
  authData: authDataIn,
333
334
  disable,
335
+ transport,
336
+ pullIntervalMs = 30_000,
334
337
  ...props
335
338
  }: Omit<ZeroOptions<Schema, ZeroMutators>, 'schema' | 'mutators'> & {
336
339
  children: ReactNode
337
340
  authData?: AuthData | null
338
341
  disable?: boolean
342
+ // 'http-pull' runs the stock zero client over stateless HTTP pull/push
343
+ // (no websocket, no resident server state) by intercepting the sync
344
+ // socket for this instance's server origin. see httpPullTransport.ts.
345
+ transport?: 'http-pull'
346
+ // http-pull only: every open connection also pulls on this interval so
347
+ // server-initiated changes arrive without a client-side trigger
348
+ pullIntervalMs?: number
339
349
  }) => {
340
350
  const authData = (authDataIn ?? null) as AuthData
341
351
 
@@ -374,6 +384,7 @@ export function createZeroClient<
374
384
  .sort(([a], [b]) => (a < b ? -1 : 1)),
375
385
  hasAuth,
376
386
  generation,
387
+ transport,
377
388
  ])
378
389
 
379
390
  // create/rotate in an effect — commit-safe: a discarded concurrent render
@@ -391,6 +402,14 @@ export function createZeroClient<
391
402
  ZeroOptions<Schema, ZeroMutators>,
392
403
  'schema' | 'mutators'
393
404
  >
405
+ // install before construction so the instance's first connect goes
406
+ // through HTTP. per-origin idempotent — rotation reuses the install.
407
+ if (transport === 'http-pull') {
408
+ if (typeof options.server !== 'string') {
409
+ throw new Error(`transport 'http-pull' requires a server URL`)
410
+ }
411
+ ensureHttpPullTransport({ origin: options.server, pullIntervalMs })
412
+ }
394
413
  cached = {
395
414
  key: instanceKey,
396
415
  instance: new ZeroClient<Schema, ZeroMutators>({
@@ -0,0 +1,208 @@
1
+ import { afterEach, describe, expect, test } from 'vitest'
2
+
3
+ import {
4
+ eventually,
5
+ sleep,
6
+ startZeroHttpHarness,
7
+ waitForComplete,
8
+ type ZeroHttpHarness,
9
+ } from './testHarness'
10
+
11
+ let harness: ZeroHttpHarness | undefined
12
+
13
+ type UserRow = { id: string; name: string }
14
+ type MemberRow = { id: string; projectId: string; userId: string }
15
+ type ProjectWithMembers = {
16
+ id: string
17
+ ownerId: string
18
+ name: string
19
+ members: MemberRow[]
20
+ }
21
+
22
+ afterEach(async () => {
23
+ await harness?.close()
24
+ harness = undefined
25
+ })
26
+
27
+ describe('zero-http auth parity', () => {
28
+ test('real clients only emit rows visible to their auth token', async () => {
29
+ harness = await startZeroHttpHarness({
30
+ seed: {
31
+ user: [
32
+ { id: 'u1', name: 'ada' },
33
+ { id: 'u2', name: 'ben' },
34
+ ],
35
+ project: [
36
+ { id: 'p-u1', ownerId: 'u1', name: 'u1 private' },
37
+ { id: 'p-u2', ownerId: 'u2', name: 'u2 private' },
38
+ { id: 'p-shared', ownerId: 'u2', name: 'shared' },
39
+ ],
40
+ member: [
41
+ { id: 'm-u1-owner', projectId: 'p-u1', userId: 'u1' },
42
+ { id: 'm-u2-owner', projectId: 'p-u2', userId: 'u2' },
43
+ { id: 'm-shared-u1', projectId: 'p-shared', userId: 'u1' },
44
+ { id: 'm-shared-u2', projectId: 'p-shared', userId: 'u2' },
45
+ ],
46
+ },
47
+ })
48
+ const u1 = harness.createZero('u1')
49
+ const u2 = harness.createZero('u2')
50
+
51
+ const u1Users = u1.query.user.materialize()
52
+ const u1Projects = u1.query.project.related('members').materialize()
53
+ const u2Users = u2.query.user.materialize()
54
+ const u2Projects = u2.query.project.related('members').materialize()
55
+
56
+ const u1UserEmissions: UserRow[][] = []
57
+ const u1ProjectEmissions: ProjectWithMembers[][] = []
58
+ const u2UserEmissions: UserRow[][] = []
59
+ const u2ProjectEmissions: ProjectWithMembers[][] = []
60
+
61
+ const stops = [
62
+ captureRows(u1Users, u1UserEmissions, normalizeUsers),
63
+ captureRows(u1Projects, u1ProjectEmissions, normalizeProjects),
64
+ captureRows(u2Users, u2UserEmissions, normalizeUsers),
65
+ captureRows(u2Projects, u2ProjectEmissions, normalizeProjects),
66
+ ]
67
+
68
+ try {
69
+ const [u1UsersComplete, u1ProjectsComplete, u2UsersComplete, u2ProjectsComplete] =
70
+ await Promise.all([
71
+ waitForComplete<UserRow[]>(u1Users),
72
+ waitForComplete<ProjectWithMembers[]>(u1Projects),
73
+ waitForComplete<UserRow[]>(u2Users),
74
+ waitForComplete<ProjectWithMembers[]>(u2Projects),
75
+ ])
76
+
77
+ expect(normalizeUsers(u1UsersComplete)).toEqual([{ id: 'u1', name: 'ada' }])
78
+ expect(normalizeProjects(u1ProjectsComplete)).toEqual([
79
+ {
80
+ id: 'p-shared',
81
+ ownerId: 'u2',
82
+ name: 'shared',
83
+ members: [
84
+ { id: 'm-shared-u1', projectId: 'p-shared', userId: 'u1' },
85
+ { id: 'm-shared-u2', projectId: 'p-shared', userId: 'u2' },
86
+ ],
87
+ },
88
+ {
89
+ id: 'p-u1',
90
+ ownerId: 'u1',
91
+ name: 'u1 private',
92
+ members: [{ id: 'm-u1-owner', projectId: 'p-u1', userId: 'u1' }],
93
+ },
94
+ ])
95
+
96
+ expect(normalizeUsers(u2UsersComplete)).toEqual([{ id: 'u2', name: 'ben' }])
97
+ expect(normalizeProjects(u2ProjectsComplete)).toEqual([
98
+ {
99
+ id: 'p-shared',
100
+ ownerId: 'u2',
101
+ name: 'shared',
102
+ members: [
103
+ { id: 'm-shared-u1', projectId: 'p-shared', userId: 'u1' },
104
+ { id: 'm-shared-u2', projectId: 'p-shared', userId: 'u2' },
105
+ ],
106
+ },
107
+ {
108
+ id: 'p-u2',
109
+ ownerId: 'u2',
110
+ name: 'u2 private',
111
+ members: [{ id: 'm-u2-owner', projectId: 'p-u2', userId: 'u2' }],
112
+ },
113
+ ])
114
+
115
+ assertNoUserEmission(u1UserEmissions, 'u2')
116
+ assertNoProjectEmission(u1ProjectEmissions, 'p-u2', 'm-u2-owner')
117
+ assertNoUserEmission(u2UserEmissions, 'u1')
118
+ assertNoProjectEmission(u2ProjectEmissions, 'p-u1', 'm-u1-owner')
119
+
120
+ await harness.transport.pull()
121
+ await sleep(50)
122
+ assertNoUserEmission(u1UserEmissions, 'u2')
123
+ assertNoProjectEmission(u1ProjectEmissions, 'p-u2', 'm-u2-owner')
124
+ assertNoUserEmission(u2UserEmissions, 'u1')
125
+ assertNoProjectEmission(u2ProjectEmissions, 'p-u1', 'm-u1-owner')
126
+ } finally {
127
+ for (const stop of stops) stop()
128
+ u1Users.destroy()
129
+ u1Projects.destroy()
130
+ u2Users.destroy()
131
+ u2Projects.destroy()
132
+ }
133
+ })
134
+
135
+ test('unknown token 401 reaches needs-auth without materializing data', async () => {
136
+ harness = await startZeroHttpHarness({
137
+ seed: {
138
+ user: [{ id: 'u1', name: 'ada' }],
139
+ project: [{ id: 'p-u1', ownerId: 'u1', name: 'u1 private' }],
140
+ member: [{ id: 'm-u1-owner', projectId: 'p-u1', userId: 'u1' }],
141
+ },
142
+ })
143
+ const unknown = harness.createZero('missing')
144
+ const projects = unknown.query.project.related('members').materialize()
145
+ const emissions: ProjectWithMembers[][] = []
146
+ const stop = captureRows(projects, emissions, normalizeProjects)
147
+
148
+ try {
149
+ await eventually(() =>
150
+ expect(unknown.connection.state.current.name).toBe('needs-auth'),
151
+ )
152
+ await sleep(50)
153
+
154
+ expect(projects.data).toEqual([])
155
+ expect(emissions.every((emission) => emission.length === 0)).toBe(true)
156
+ } finally {
157
+ stop()
158
+ projects.destroy()
159
+ }
160
+ })
161
+ })
162
+
163
+ // listener data is Immutable<T>; normalize maps to fresh mutable rows
164
+ function captureRows<T>(
165
+ view: { addListener(listener: (data: any, resultType: string) => void): () => void },
166
+ emissions: T[],
167
+ normalize: (data: T) => T,
168
+ ) {
169
+ return view.addListener((data) => {
170
+ emissions.push(normalize(data))
171
+ })
172
+ }
173
+
174
+ function assertNoUserEmission(emissions: UserRow[][], privateUserID: string) {
175
+ for (const emission of emissions) {
176
+ expect(emission.map((row) => row.id)).not.toContain(privateUserID)
177
+ }
178
+ }
179
+
180
+ function assertNoProjectEmission(
181
+ emissions: ProjectWithMembers[][],
182
+ privateProjectID: string,
183
+ privateMemberID: string,
184
+ ) {
185
+ for (const emission of emissions) {
186
+ expect(emission.map((row) => row.id)).not.toContain(privateProjectID)
187
+ expect(
188
+ emission.flatMap((row) => row.members.map((member) => member.id)),
189
+ ).not.toContain(privateMemberID)
190
+ }
191
+ }
192
+
193
+ function normalizeUsers(users: UserRow[]) {
194
+ return clone(users).sort((a, b) => a.id.localeCompare(b.id))
195
+ }
196
+
197
+ function normalizeProjects(projects: ProjectWithMembers[]) {
198
+ return clone(projects)
199
+ .map((project) => ({
200
+ ...project,
201
+ members: [...project.members].sort((a, b) => a.id.localeCompare(b.id)),
202
+ }))
203
+ .sort((a, b) => a.id.localeCompare(b.id))
204
+ }
205
+
206
+ function clone<T>(value: T): T {
207
+ return JSON.parse(JSON.stringify(value)) as T
208
+ }
@@ -0,0 +1,147 @@
1
+ import { afterEach, expect, test } from 'vitest'
2
+
3
+ import {
4
+ eventually,
5
+ startZeroHttpHarness,
6
+ waitForComplete,
7
+ type ZeroHttpHarness,
8
+ } from './testHarness'
9
+
10
+ let harness: ZeroHttpHarness | undefined
11
+
12
+ afterEach(async () => {
13
+ await harness?.close()
14
+ harness = undefined
15
+ })
16
+
17
+ test('mutation burst converges while explicit pulls churn', async () => {
18
+ let clientGroupID = ''
19
+ const pullErrors: unknown[] = []
20
+
21
+ harness = await startZeroHttpHarness({
22
+ seed: {
23
+ user: [{ id: 'u1', name: 'ada' }],
24
+ project: [],
25
+ member: [],
26
+ },
27
+ interceptFetch: (next) => async (input, init) => {
28
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined
29
+ if (body?.clientGroupID) clientGroupID = body.clientGroupID
30
+ return next(input, init)
31
+ },
32
+ })
33
+ const zero = harness.createZero('u1')
34
+ const view = zero.query.project.materialize()
35
+ await waitForComplete<any[]>(view)
36
+
37
+ const timer = setInterval(() => {
38
+ void harness?.transport.pull().catch((error) => pullErrors.push(error))
39
+ }, 2)
40
+
41
+ try {
42
+ const mutations = Array.from({ length: 15 }, (_, index) =>
43
+ zero.mutate.project.create({
44
+ id: `p${index + 1}`,
45
+ ownerId: 'u1',
46
+ name: `project ${index + 1}`,
47
+ }),
48
+ )
49
+ await Promise.all(mutations.map((mutation) => mutation.client))
50
+ await Promise.all(mutations.map((mutation) => mutation.server))
51
+ await harness.transport.pull()
52
+
53
+ await eventually(() => {
54
+ expect(projectIDs(view.data)).toEqual(
55
+ Array.from({ length: 15 }, (_, index) => `p${index + 1}`).sort(),
56
+ )
57
+ })
58
+ } finally {
59
+ clearInterval(timer)
60
+ }
61
+
62
+ expect(pullErrors).toEqual([])
63
+ expect(zero.connection.state.current.name).toBe('connected')
64
+ expect(
65
+ harness.server
66
+ .rows('project')
67
+ .map((row) => row.id)
68
+ .sort(),
69
+ ).toEqual(Array.from({ length: 15 }, (_, index) => `p${index + 1}`).sort())
70
+
71
+ const pull = await rawPull(harness, clientGroupID)
72
+ expect(Object.values(pull.lastMutationIDChanges)).toContain(15)
73
+ view.destroy()
74
+ })
75
+
76
+ test('two clients sharing a client group converge without cookie fights', async () => {
77
+ const clientGroups = new Set<string>()
78
+ const clientIDs = new Set<string>()
79
+
80
+ harness = await startZeroHttpHarness({
81
+ seed: {
82
+ user: [{ id: 'u1', name: 'ada' }],
83
+ project: [{ id: 'p1', ownerId: 'u1', name: 'first' }],
84
+ member: [],
85
+ },
86
+ interceptFetch: (next) => async (input, init) => {
87
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined
88
+ if (body?.clientGroupID) clientGroups.add(body.clientGroupID)
89
+ if (body?.clientID) clientIDs.add(body.clientID)
90
+ return next(input, init)
91
+ },
92
+ })
93
+
94
+ const sharedStorageKey = 'zero-http-shared-client-group'
95
+ const zeroA = harness.createZero('u1', { storageKey: sharedStorageKey })
96
+ const zeroB = harness.createZero('u1', { storageKey: sharedStorageKey })
97
+ const viewA = zeroA.query.project.materialize()
98
+ const viewB = zeroB.query.project.materialize()
99
+ await waitForComplete<any[]>(viewA)
100
+ await waitForComplete<any[]>(viewB)
101
+
102
+ expect(harness.transport.connections).toBe(2)
103
+ expect(clientGroups.size).toBe(1)
104
+ expect(clientIDs.size).toBe(2)
105
+
106
+ const mutation = zeroA.mutate.project.create({
107
+ id: 'p2',
108
+ ownerId: 'u1',
109
+ name: 'second',
110
+ })
111
+ await mutation.client
112
+ await mutation.server
113
+ await harness.transport.pull()
114
+
115
+ await eventually(() => {
116
+ expect(projectIDs(viewA.data)).toEqual(['p1', 'p2'])
117
+ expect(projectIDs(viewB.data)).toEqual(['p1', 'p2'])
118
+ })
119
+ expect(zeroA.connection.state.current.name).toBe('connected')
120
+ expect(zeroB.connection.state.current.name).toBe('connected')
121
+
122
+ viewA.destroy()
123
+ viewB.destroy()
124
+ })
125
+
126
+ async function rawPull(harness: ZeroHttpHarness, clientGroupID: string) {
127
+ const response = await fetch(`${harness.server.url}/pull`, {
128
+ method: 'POST',
129
+ headers: {
130
+ authorization: 'Bearer token-u1',
131
+ 'content-type': 'application/json',
132
+ },
133
+ body: JSON.stringify({
134
+ clientID: 'raw-pull',
135
+ clientGroupID,
136
+ cookie: null,
137
+ }),
138
+ })
139
+ expect(response.status).toBe(200)
140
+ return response.json() as Promise<{
141
+ lastMutationIDChanges: Record<string, number>
142
+ }>
143
+ }
144
+
145
+ function projectIDs(rows: any[]) {
146
+ return rows.map((row) => row.id).sort()
147
+ }
@@ -0,0 +1,82 @@
1
+ import { createSchema, relationships, string, table } from '@rocicorp/zero'
2
+
3
+ import type { Transaction } from '@rocicorp/zero'
4
+
5
+ const user = table('user')
6
+ .columns({
7
+ id: string(),
8
+ name: string(),
9
+ })
10
+ .primaryKey('id')
11
+
12
+ const project = table('project')
13
+ .columns({
14
+ id: string(),
15
+ ownerId: string(),
16
+ name: string(),
17
+ })
18
+ .primaryKey('id')
19
+
20
+ const member = table('member')
21
+ .columns({
22
+ id: string(),
23
+ projectId: string(),
24
+ userId: string(),
25
+ })
26
+ .primaryKey('id')
27
+
28
+ export const zeroHttpFixtureSchema = createSchema({
29
+ tables: [user, project, member],
30
+ relationships: [
31
+ relationships(project, ({ many }) => ({
32
+ members: many({
33
+ sourceField: ['id'],
34
+ destField: ['projectId'],
35
+ destSchema: member,
36
+ }),
37
+ })),
38
+ ],
39
+ enableLegacyQueries: true,
40
+ })
41
+
42
+ type FixtureTransaction = Transaction<typeof zeroHttpFixtureSchema>
43
+
44
+ export type ProjectCreateArgs = {
45
+ id: string
46
+ ownerId: string
47
+ name: string
48
+ }
49
+
50
+ export type ProjectRenameArgs = {
51
+ id: string
52
+ name: string
53
+ }
54
+
55
+ export type MemberAddArgs = {
56
+ id: string
57
+ projectId: string
58
+ userId: string
59
+ }
60
+
61
+ export type MemberRemoveArgs = {
62
+ id: string
63
+ }
64
+
65
+ export const zeroHttpFixtureMutators = {
66
+ project: {
67
+ create: async (tx: FixtureTransaction, args: ProjectCreateArgs) => {
68
+ await tx.mutate.project.insert(args)
69
+ },
70
+ rename: async (tx: FixtureTransaction, args: ProjectRenameArgs) => {
71
+ await tx.mutate.project.update(args)
72
+ },
73
+ },
74
+ member: {
75
+ add: async (tx: FixtureTransaction, args: MemberAddArgs) => {
76
+ await tx.mutate.member.insert(args)
77
+ },
78
+ remove: async (tx: FixtureTransaction, args: MemberRemoveArgs) => {
79
+ await tx.mutate.member.delete({ id: args.id })
80
+ },
81
+ },
82
+ }