on-zero 0.6.3 → 0.6.5
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/createZeroClient.cjs +111 -12
- package/dist/cjs/createZeroClient.connection.test.cjs +163 -0
- package/dist/cjs/createZeroClient.connection.test.native.js +242 -0
- package/dist/cjs/createZeroClient.connection.test.native.js.map +1 -0
- package/dist/cjs/createZeroClient.native.js +145 -12
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/createZeroClient.recovery.test.cjs +52 -0
- package/dist/cjs/createZeroClient.recovery.test.native.js +52 -0
- package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.cjs +140 -14
- package/dist/cjs/helpers/recoverZeroClient.native.js +155 -22
- package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.test.cjs +181 -0
- package/dist/cjs/helpers/recoverZeroClient.test.native.js +209 -0
- package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +1 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/multi.cjs +13 -0
- package/dist/cjs/multi.native.js +35 -0
- package/dist/cjs/multi.native.js.map +1 -1
- package/dist/cjs/multiInstance.test.cjs +12 -0
- package/dist/cjs/multiInstance.test.native.js +21 -0
- package/dist/cjs/multiInstance.test.native.js.map +1 -1
- package/dist/cjs/multiPartition.test.cjs +42 -0
- package/dist/cjs/multiPartition.test.native.js +51 -0
- package/dist/cjs/multiPartition.test.native.js.map +1 -0
- package/dist/esm/createZeroClient.connection.test.mjs +164 -0
- package/dist/esm/createZeroClient.connection.test.mjs.map +1 -0
- package/dist/esm/createZeroClient.connection.test.native.js +240 -0
- package/dist/esm/createZeroClient.connection.test.native.js.map +1 -0
- package/dist/esm/createZeroClient.mjs +112 -13
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +146 -13
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.mjs +52 -0
- package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.native.js +52 -0
- package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.mjs +140 -15
- package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.native.js +155 -23
- package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs +182 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js +210 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.mjs +2 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/multi.mjs +13 -1
- package/dist/esm/multi.mjs.map +1 -1
- package/dist/esm/multi.native.js +35 -1
- package/dist/esm/multi.native.js.map +1 -1
- package/dist/esm/multiInstance.test.mjs +12 -0
- package/dist/esm/multiInstance.test.mjs.map +1 -1
- package/dist/esm/multiInstance.test.native.js +21 -0
- package/dist/esm/multiInstance.test.native.js.map +1 -1
- package/dist/esm/multiPartition.test.mjs +43 -0
- package/dist/esm/multiPartition.test.mjs.map +1 -0
- package/dist/esm/multiPartition.test.native.js +49 -0
- package/dist/esm/multiPartition.test.native.js.map +1 -0
- package/lint/ssr-guards.js +164 -0
- package/package.json +2 -2
- package/readme.md +126 -0
- package/src/createZeroClient.connection.test.tsx +162 -0
- package/src/createZeroClient.recovery.test.tsx +68 -0
- package/src/createZeroClient.tsx +188 -13
- package/src/helpers/recoverZeroClient.test.ts +204 -1
- package/src/helpers/recoverZeroClient.ts +263 -42
- package/src/index.ts +3 -0
- package/src/multi.ts +33 -0
- package/src/multiInstance.test.tsx +13 -0
- package/src/multiPartition.test.ts +38 -0
- package/types/createZeroClient.connection.test.d.ts +5 -0
- package/types/createZeroClient.connection.test.d.ts.map +1 -0
- package/types/createZeroClient.d.ts +17 -0
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/createZeroServer.d.ts +4 -4
- package/types/helpers/recoverZeroClient.d.ts +15 -1
- package/types/helpers/recoverZeroClient.d.ts.map +1 -1
- package/types/index.d.ts +1 -1
- package/types/index.d.ts.map +1 -1
- package/types/multi.d.ts +1 -0
- package/types/multi.d.ts.map +1 -1
- package/types/multiPartition.test.d.ts +2 -0
- package/types/multiPartition.test.d.ts.map +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { assertZeroInstancePartition } from "./multi.mjs";
|
|
3
|
+
describe("assertZeroInstancePartition", () => {
|
|
4
|
+
const control = {
|
|
5
|
+
user: 1,
|
|
6
|
+
workspace: 1
|
|
7
|
+
};
|
|
8
|
+
const project = {
|
|
9
|
+
message: 1,
|
|
10
|
+
thread: 1
|
|
11
|
+
};
|
|
12
|
+
test("passes when every namespace belongs to exactly one partition", () => {
|
|
13
|
+
expect(() => assertZeroInstancePartition("query namespace", {
|
|
14
|
+
user: {},
|
|
15
|
+
workspace: {},
|
|
16
|
+
message: {},
|
|
17
|
+
thread: {}
|
|
18
|
+
}, {
|
|
19
|
+
control,
|
|
20
|
+
project
|
|
21
|
+
})).not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
test("throws on a namespace missing from every partition (the planGrant drift)", () => {
|
|
24
|
+
expect(() => assertZeroInstancePartition("query namespace", {
|
|
25
|
+
user: {},
|
|
26
|
+
planGrant: {}
|
|
27
|
+
}, {
|
|
28
|
+
control,
|
|
29
|
+
project
|
|
30
|
+
})).toThrow(/planGrant.*missing from the instance partition/);
|
|
31
|
+
});
|
|
32
|
+
test("throws on a namespace claimed by more than one partition", () => {
|
|
33
|
+
expect(() => assertZeroInstancePartition("query namespace", {
|
|
34
|
+
user: {}
|
|
35
|
+
}, {
|
|
36
|
+
control,
|
|
37
|
+
project: {
|
|
38
|
+
user: 1
|
|
39
|
+
}
|
|
40
|
+
})).toThrow(/user.*more than one instance partition/);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
//# sourceMappingURL=multiPartition.test.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["describe","expect","test","assertZeroInstancePartition","control","user","workspace","project","message","thread","not","toThrow","planGrant"],"sources":["../../src/multiPartition.test.ts"],"sourcesContent":[null],"mappings":"AAAA,SAASA,QAAA,EAAUC,MAAA,EAAQC,IAAA,QAAY;AAEvC,SAASC,2BAAA,QAAmC;AAE5CH,QAAA,CAAS,+BAA+B,MAAM;EAC5C,MAAMI,OAAA,GAAU;IAAEC,IAAA,EAAM;IAAGC,SAAA,EAAW;EAAE;EACxC,MAAMC,OAAA,GAAU;IAAEC,OAAA,EAAS;IAAGC,MAAA,EAAQ;EAAE;EAExCP,IAAA,CAAK,gEAAgE,MAAM;IACzED,MAAA,CAAO,MACLE,2BAAA,CACE,mBACA;MAAEE,IAAA,EAAM,CAAC;MAAGC,SAAA,EAAW,CAAC;MAAGE,OAAA,EAAS,CAAC;MAAGC,MAAA,EAAQ,CAAC;IAAE,GACnD;MAAEL,OAAA;MAASG;IAAQ,CACrB,CACF,EAAEG,GAAA,CAAIC,OAAA,CAAQ;EAChB,CAAC;EAEDT,IAAA,CAAK,4EAA4E,MAAM;IACrFD,MAAA,CAAO,MACLE,2BAAA,CACE,mBACA;MAAEE,IAAA,EAAM,CAAC;MAAGO,SAAA,EAAW,CAAC;IAAE,GAC1B;MAAER,OAAA;MAASG;IAAQ,CACrB,CACF,EAAEI,OAAA,CAAQ,gDAAgD;EAC5D,CAAC;EAEDT,IAAA,CAAK,4DAA4D,MAAM;IACrED,MAAA,CAAO,MACLE,2BAAA,CACE,mBACA;MAAEE,IAAA,EAAM,CAAC;IAAE,GACX;MAAED,OAAA;MAASG,OAAA,EAAS;QAAEF,IAAA,EAAM;MAAE;IAAE,CAClC,CACF,EAAEM,OAAA,CAAQ,wCAAwC;EACpD,CAAC;AACH,CAAC","ignoreList":[]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { assertZeroInstancePartition } from "./multi.native.js";
|
|
3
|
+
describe("assertZeroInstancePartition", function () {
|
|
4
|
+
var control = {
|
|
5
|
+
user: 1,
|
|
6
|
+
workspace: 1
|
|
7
|
+
};
|
|
8
|
+
var project = {
|
|
9
|
+
message: 1,
|
|
10
|
+
thread: 1
|
|
11
|
+
};
|
|
12
|
+
test("passes when every namespace belongs to exactly one partition", function () {
|
|
13
|
+
expect(function () {
|
|
14
|
+
return assertZeroInstancePartition("query namespace", {
|
|
15
|
+
user: {},
|
|
16
|
+
workspace: {},
|
|
17
|
+
message: {},
|
|
18
|
+
thread: {}
|
|
19
|
+
}, {
|
|
20
|
+
control,
|
|
21
|
+
project
|
|
22
|
+
});
|
|
23
|
+
}).not.toThrow();
|
|
24
|
+
});
|
|
25
|
+
test("throws on a namespace missing from every partition (the planGrant drift)", function () {
|
|
26
|
+
expect(function () {
|
|
27
|
+
return assertZeroInstancePartition("query namespace", {
|
|
28
|
+
user: {},
|
|
29
|
+
planGrant: {}
|
|
30
|
+
}, {
|
|
31
|
+
control,
|
|
32
|
+
project
|
|
33
|
+
});
|
|
34
|
+
}).toThrow(/planGrant.*missing from the instance partition/);
|
|
35
|
+
});
|
|
36
|
+
test("throws on a namespace claimed by more than one partition", function () {
|
|
37
|
+
expect(function () {
|
|
38
|
+
return assertZeroInstancePartition("query namespace", {
|
|
39
|
+
user: {}
|
|
40
|
+
}, {
|
|
41
|
+
control,
|
|
42
|
+
project: {
|
|
43
|
+
user: 1
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}).toThrow(/user.*more than one instance partition/);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
//# sourceMappingURL=multiPartition.test.native.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["describe","expect","test","assertZeroInstancePartition","control","user","workspace","project","message","thread","not","toThrow","planGrant"],"sources":["../../src/multiPartition.test.ts"],"sourcesContent":[null],"mappings":"AAAA,SAASA,QAAA,EAAUC,MAAA,EAAQC,IAAA,QAAY;AAEvC,SAASC,2BAAA,QAAmC;AAE5CH,QAAA,CAAS,+BAA+B,YAAM;EAC5C,IAAAI,OAAM;IACNC,IAAM;IAENC,SAAK;EACH;EAAA,IAAOC,OACL;IAAAC,OACE;IAAAC,MACE;EAAiD;EAChCP,IACrB;IACFD,MAAM,aAAQ;MACf,OAAAE,2BAAA;QAEIE,IAAA;QACHC,SAAA;QAAOE,OACL;QACEC,MAAA;MAAA,GACA;QACAL,OAAE;QACJG;MACA;IACH,GAAAG,GAAA,CAAAC,OAAA;EAED;EACET,IAAA;IAAAD,MAAO,aACL;MAAA,OACEE,2BAAA;QACAE,IAAE,IAAM;QACRO,SAAE,EAAS;MACb;QACAR,OAAQ;QACXG;MACF","ignoreList":[]}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canonical oxlint plugin for zero model SSR guards — shipped WITH on-zero so
|
|
3
|
+
* every consumer references ONE source instead of copying it. add it as a
|
|
4
|
+
* jsPlugins entry in the consumer's .oxlintrc.json.
|
|
5
|
+
*
|
|
6
|
+
* it enforces on-zero's own footgun contract: run() and
|
|
7
|
+
* server.asyncTasks.push() must appear only inside a tree-shakeable
|
|
8
|
+
* `if (process.env.VITE_ENVIRONMENT === 'ssr')` block, never in the
|
|
9
|
+
* non-tree-shakeable `!== 'ssr'` early-return form.
|
|
10
|
+
*
|
|
11
|
+
* valid guards (tree-shakeable):
|
|
12
|
+
* - if (isSSR) { ... } — `typeof window === 'undefined'` constant
|
|
13
|
+
* - if (process.env.VITE_ENVIRONMENT === 'ssr') { ... } — legacy
|
|
14
|
+
*
|
|
15
|
+
* `if (server)` is NOT valid because it doesn't tree-shake.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function isEnvSSRCheck(node) {
|
|
19
|
+
if (!node) return false
|
|
20
|
+
|
|
21
|
+
// process.env.VITE_ENVIRONMENT === 'ssr'
|
|
22
|
+
if (node.type === 'BinaryExpression' && node.operator === '===') {
|
|
23
|
+
const left = node.left
|
|
24
|
+
const right = node.right
|
|
25
|
+
if (
|
|
26
|
+
left.type === 'MemberExpression' &&
|
|
27
|
+
left.object?.type === 'MemberExpression' &&
|
|
28
|
+
left.object?.object?.name === 'process' &&
|
|
29
|
+
left.object?.property?.name === 'env' &&
|
|
30
|
+
left.property?.name === 'VITE_ENVIRONMENT' &&
|
|
31
|
+
right.type === 'Literal' &&
|
|
32
|
+
right.value === 'ssr'
|
|
33
|
+
) {
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// check compound conditions: if (x && isSSR)
|
|
39
|
+
if (node.type === 'LogicalExpression' && node.operator === '&&') {
|
|
40
|
+
return isEnvSSRCheck(node.left) || isEnvSSRCheck(node.right)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isSSRGuard(node) {
|
|
47
|
+
if (node.type !== 'IfStatement') return false
|
|
48
|
+
return isEnvSSRCheck(node.test)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isModelFile(filename) {
|
|
52
|
+
return (
|
|
53
|
+
filename.includes('/data/mutations/') &&
|
|
54
|
+
!filename.includes('/data/mutations/helpers/')
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// detect: if (process.env.VITE_ENVIRONMENT !== 'ssr') return
|
|
59
|
+
function isSSREarlyReturn(node) {
|
|
60
|
+
if (node.type !== 'IfStatement') return false
|
|
61
|
+
const test = node.test
|
|
62
|
+
if (
|
|
63
|
+
test.type === 'BinaryExpression' &&
|
|
64
|
+
test.operator === '!==' &&
|
|
65
|
+
test.left?.type === 'MemberExpression' &&
|
|
66
|
+
test.left?.object?.type === 'MemberExpression' &&
|
|
67
|
+
test.left?.object?.object?.name === 'process' &&
|
|
68
|
+
test.left?.object?.property?.name === 'env' &&
|
|
69
|
+
test.left?.property?.name === 'VITE_ENVIRONMENT' &&
|
|
70
|
+
test.right?.type === 'Literal' &&
|
|
71
|
+
test.right?.value === 'ssr'
|
|
72
|
+
) {
|
|
73
|
+
const body =
|
|
74
|
+
node.consequent.type === 'BlockStatement' ? node.consequent.body : [node.consequent]
|
|
75
|
+
return body.some((s) => s.type === 'ReturnStatement')
|
|
76
|
+
}
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function createSSRGuardRule(matchCallee, messageId, message) {
|
|
81
|
+
return {
|
|
82
|
+
meta: {
|
|
83
|
+
type: 'problem',
|
|
84
|
+
messages: { [messageId]: message },
|
|
85
|
+
},
|
|
86
|
+
create(context) {
|
|
87
|
+
const filename = context.filename || context.getFilename()
|
|
88
|
+
if (!isModelFile(filename)) return {}
|
|
89
|
+
|
|
90
|
+
let isServerOnly = false
|
|
91
|
+
let guardDepth = 0
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ImportDeclaration(node) {
|
|
95
|
+
if (node.source.value === 'server-only') {
|
|
96
|
+
isServerOnly = true
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
IfStatement(node) {
|
|
100
|
+
if (isSSRGuard(node)) guardDepth++
|
|
101
|
+
},
|
|
102
|
+
'IfStatement:exit'(node) {
|
|
103
|
+
if (isSSRGuard(node)) guardDepth--
|
|
104
|
+
},
|
|
105
|
+
CallExpression(node) {
|
|
106
|
+
if (isServerOnly) return
|
|
107
|
+
if (matchCallee(node) && guardDepth === 0) {
|
|
108
|
+
context.report({ node, messageId })
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const runCallRule = createSSRGuardRule(
|
|
117
|
+
(node) => node.callee.type === 'Identifier' && node.callee.name === 'run',
|
|
118
|
+
'missingGuard',
|
|
119
|
+
'run() is a footgun on client, must be inside `if (process.env.VITE_ENVIRONMENT === "ssr")`',
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const asyncTasksRule = createSSRGuardRule(
|
|
123
|
+
(node) => {
|
|
124
|
+
const callee = node.callee
|
|
125
|
+
return (
|
|
126
|
+
callee.type === 'MemberExpression' &&
|
|
127
|
+
callee.property?.name === 'push' &&
|
|
128
|
+
callee.object?.type === 'MemberExpression' &&
|
|
129
|
+
callee.object?.property?.name === 'asyncTasks'
|
|
130
|
+
)
|
|
131
|
+
},
|
|
132
|
+
'missingGuard',
|
|
133
|
+
'server.asyncTasks.push must be inside `if (process.env.VITE_ENVIRONMENT === "ssr")`',
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const noEarlyReturnRule = {
|
|
137
|
+
meta: {
|
|
138
|
+
type: 'problem',
|
|
139
|
+
messages: {
|
|
140
|
+
noEarlyReturn:
|
|
141
|
+
'Use `if (process.env.VITE_ENVIRONMENT === "ssr") { ... }` block, not `!== "ssr"` early return',
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
create(context) {
|
|
145
|
+
const filename = context.filename || context.getFilename()
|
|
146
|
+
if (!isModelFile(filename)) return {}
|
|
147
|
+
return {
|
|
148
|
+
IfStatement(node) {
|
|
149
|
+
if (isSSREarlyReturn(node)) {
|
|
150
|
+
context.report({ node, messageId: 'noEarlyReturn' })
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export default {
|
|
158
|
+
meta: { name: 'ssr-guards' },
|
|
159
|
+
rules: {
|
|
160
|
+
'run-call': runCallRule,
|
|
161
|
+
'async-tasks': asyncTasksRule,
|
|
162
|
+
'no-early-return': noEarlyReturnRule,
|
|
163
|
+
},
|
|
164
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "on-zero",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "A typed layer over @rocicorp/zero with queries, mutations, and permissions",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"source": "src/index.ts",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
}
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@take-out/helpers": "0.6.
|
|
86
|
+
"@take-out/helpers": "0.6.5",
|
|
87
87
|
"chokidar": "^4.0.3",
|
|
88
88
|
"citty": "^0.1.6",
|
|
89
89
|
"valibot": "^1.1.0"
|
package/readme.md
CHANGED
|
@@ -578,6 +578,26 @@ constraints:
|
|
|
578
578
|
- the server stays single: one `createZeroServer` per backend process.
|
|
579
579
|
- omitting `instanceName` keeps the exact single-instance behavior.
|
|
580
580
|
|
|
581
|
+
**guarding the partition against drift.** a generated namespace that ends up in
|
|
582
|
+
NEITHER instance's split silently un-registers its queries (a `useQuery` then
|
|
583
|
+
throws "query not registered" at runtime, error-boundarying a whole screen). run
|
|
584
|
+
`assertZeroInstancePartition` at module eval over each split so the drift is a
|
|
585
|
+
boot throw instead — it also catches a namespace listed in more than one
|
|
586
|
+
partition:
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
import { assertZeroInstancePartition } from 'on-zero/multi'
|
|
590
|
+
|
|
591
|
+
assertZeroInstancePartition('query namespace', groupedQueries, {
|
|
592
|
+
control: controlQueries,
|
|
593
|
+
project: projectQueries,
|
|
594
|
+
})
|
|
595
|
+
assertZeroInstancePartition('model namespace', models, {
|
|
596
|
+
control: controlModels,
|
|
597
|
+
project: projectModels,
|
|
598
|
+
})
|
|
599
|
+
```
|
|
600
|
+
|
|
581
601
|
### server validation hooks
|
|
582
602
|
|
|
583
603
|
add custom validation for all queries and mutations:
|
|
@@ -748,6 +768,112 @@ import { ensureAuth } from 'on-zero'
|
|
|
748
768
|
const auth = ensureAuth() // AuthData (throws if not authenticated)
|
|
749
769
|
```
|
|
750
770
|
|
|
771
|
+
## recovery
|
|
772
|
+
|
|
773
|
+
on-zero self-heals a client whose local sync state is lost or rejected. this is
|
|
774
|
+
**on by default** — a consumer that passes nothing gets the full behavior. the
|
|
775
|
+
hooks below let you compose ONE extra behavior (gate the reload, reload
|
|
776
|
+
natively, drop a benign log, refresh auth) without re-implementing the stack.
|
|
777
|
+
|
|
778
|
+
### what's on by default
|
|
779
|
+
|
|
780
|
+
`ProvideZero` installs Zero's `onUpdateNeeded` / `onClientStateNotFound` and a
|
|
781
|
+
log sink that watches for the fatal store-loss / desync signatures. on a match it
|
|
782
|
+
drops the affected instance's local store and reloads the page ONCE. this covers:
|
|
783
|
+
|
|
784
|
+
- **update-needed** — `SchemaVersionNotSupported` (drops local state, the rows
|
|
785
|
+
are now incompatible), `NewClientGroup` / `VersionNotSupported` (reload
|
|
786
|
+
without dropping, so a sibling tab's shared IndexedDB survives).
|
|
787
|
+
- **client-state-not-found** — the store is unusable; drop it and reload.
|
|
788
|
+
- **log-only fatals** — `Expected IndexedDB not found`, native sqlite
|
|
789
|
+
`This statement has been finalized`, and repeated `Store is closed`.
|
|
790
|
+
- **the mutation/connection desync class** — `sent mutation ID … but expected`,
|
|
791
|
+
`oooMutation`, `already processed`, `InvalidConnectionRequestBaseCookie` /
|
|
792
|
+
`…LastMutationID`, `ClientNotFound`, `connection userID mismatch`. these
|
|
793
|
+
surface only through the error log, so the log sink recovers on them too.
|
|
794
|
+
|
|
795
|
+
genuinely app-infra signatures (your own synthesized ack-timeout marker, a
|
|
796
|
+
cold-boot connect timeout) are NOT in this list — keep those app-side and use
|
|
797
|
+
`benignLogFilter` to stop them from tripping recovery.
|
|
798
|
+
|
|
799
|
+
### the hooks (all optional props on `ProvideZero`)
|
|
800
|
+
|
|
801
|
+
- **`scheduleReload?: (ctx) => void`** — take over WHEN/HOW the recovery reload
|
|
802
|
+
happens. `ctx = { reason, reasonKey, dropLocalState, performReload }`. the
|
|
803
|
+
default is an immediate reload; inject this to gate it (only reload when the
|
|
804
|
+
user is on a safe surface), show a countdown toast, or reload natively —
|
|
805
|
+
then call `ctx.performReload()` to run the real deletes-then-reload work. the
|
|
806
|
+
store delete is deferred until `performReload` runs, so a gated reload never
|
|
807
|
+
leaves the app on an already-deleted store. `performReload` is idempotent.
|
|
808
|
+
|
|
809
|
+
```tsx
|
|
810
|
+
// native (expo): reload the bundle instead of location.reload()
|
|
811
|
+
<ProvideZero
|
|
812
|
+
scheduleReload={(ctx) => {
|
|
813
|
+
void ctx
|
|
814
|
+
.performReload()
|
|
815
|
+
.then(() => Updates.reloadAsync())
|
|
816
|
+
.catch(() => DevSettings.reload())
|
|
817
|
+
}}
|
|
818
|
+
…
|
|
819
|
+
/>
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
- **`beforeReload?: () => Promise<void>`** — awaited right before the reload
|
|
823
|
+
(e.g. wait for the dev origin to come back so the reload doesn't hit a
|
|
824
|
+
restarting server). composes with `scheduleReload`.
|
|
825
|
+
|
|
826
|
+
- **`benignLogFilter?: (message) => boolean`** — return `true` for a classified
|
|
827
|
+
recovery log you want treated as benign so it does NOT recover (your app's
|
|
828
|
+
cold-boot timeout, say). only affects recovery; the log still reaches the sink.
|
|
829
|
+
|
|
830
|
+
- **`refreshAuth?: () => Promise<string | undefined>`** — called when the
|
|
831
|
+
connection enters `needs-auth` (an expired token). return a fresh token and
|
|
832
|
+
on-zero reconnects in place — no reload. fires once per needs-auth transition.
|
|
833
|
+
|
|
834
|
+
- **`guardStorage?: { getItem, setItem }`** — the loop-guard's cross-reload
|
|
835
|
+
backing store. defaults to `sessionStorage` on web; inject a native KV
|
|
836
|
+
(MMKV/sqlite) on Hermes so native gets real cross-reload loop protection.
|
|
837
|
+
|
|
838
|
+
- **`connectionDataset?: boolean`** — mirror this instance's connection state
|
|
839
|
+
onto `document.body.dataset.zero*` (`zeroState`, `zeroConnected`,
|
|
840
|
+
`zeroReason`, `zeroCacheUrl`) for e2e/diagnostics. enable on ONE instance so
|
|
841
|
+
multiple instances don't clobber the dataset.
|
|
842
|
+
|
|
843
|
+
### guard + latch semantics
|
|
844
|
+
|
|
845
|
+
- a **per-reason guard** (60s window) means the SAME reason re-failing right
|
|
846
|
+
after its reload is surfaced as `fatal` instead of reload-storming; distinct
|
|
847
|
+
reasons never suppress each other. it's two-tier: an in-memory map (real loop
|
|
848
|
+
protection within a page-load, works on Hermes) plus the cross-reload
|
|
849
|
+
`guardStorage` (survives the reload to catch an immediate re-fire).
|
|
850
|
+
- a **one-reload latch** means every affected instance of a combined client
|
|
851
|
+
drops its own store but only ONE reload fires. the latch **times out** (15s)
|
|
852
|
+
so a reload that never lands (a gated/native reload, a failed `reload()`)
|
|
853
|
+
can't kill recovery for the rest of the page's life.
|
|
854
|
+
|
|
855
|
+
### remint — in-place recovery without a reload
|
|
856
|
+
|
|
857
|
+
`createZeroClient` returns **`remint(opts?)`** — the supported, native-safe
|
|
858
|
+
recovery path (a reload may never land on prod native, wedging the latch).
|
|
859
|
+
it drops the current instance's local store (unless `dropLocalState: false`) and
|
|
860
|
+
reconstructs a fresh Zero client in place, no page reload. it is rate-guarded
|
|
861
|
+
in-memory (12s between mints, 5 attempts before backing off, reset after 60s
|
|
862
|
+
stable) and returns `false` when suppressed. route your own
|
|
863
|
+
`onClientStateNotFound` to it if you need in-place recovery:
|
|
864
|
+
|
|
865
|
+
```tsx
|
|
866
|
+
const { remint, ProvideZero } = createZeroClient({ … })
|
|
867
|
+
<ProvideZero onClientStateNotFound={() => { void remint() }} … />
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
### stale-poke resume (automatic)
|
|
871
|
+
|
|
872
|
+
a recoverable stale-cookie / stale-poke error (`Server returned unexpected base
|
|
873
|
+
cookie during sync`; `Received cookie … is < than last snapshot cookie … ignoring
|
|
874
|
+
client view`) is generic Zero behavior — on-zero's connection monitor reconnects
|
|
875
|
+
instead of surfacing a fatal error, deduped per reason. no configuration.
|
|
876
|
+
|
|
751
877
|
## patterns
|
|
752
878
|
|
|
753
879
|
**server-only mutations:**
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
// a controllable connection whose state useConnectionState (useSyncExternalStore)
|
|
9
|
+
// subscribes to — set() notifies listeners so ConnectionMonitor's effect re-runs.
|
|
10
|
+
const fakeZero = vi.hoisted(() => {
|
|
11
|
+
class FakeConnectionState {
|
|
12
|
+
current: { name: string; reason?: string }
|
|
13
|
+
listeners = new Set<() => void>()
|
|
14
|
+
constructor(initial: { name: string; reason?: string }) {
|
|
15
|
+
this.current = initial
|
|
16
|
+
}
|
|
17
|
+
subscribe = (cb: () => void) => {
|
|
18
|
+
this.listeners.add(cb)
|
|
19
|
+
return () => {
|
|
20
|
+
this.listeners.delete(cb)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
set(next: { name: string; reason?: string }) {
|
|
24
|
+
this.current = next
|
|
25
|
+
for (const cb of this.listeners) cb()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class FakeZero {
|
|
30
|
+
readonly context = {}
|
|
31
|
+
readonly connection = {
|
|
32
|
+
state: new FakeConnectionState({ name: 'connecting' }),
|
|
33
|
+
connect: vi.fn(async () => {}),
|
|
34
|
+
}
|
|
35
|
+
readonly delete = vi.fn(async () => ({ errors: [] }))
|
|
36
|
+
readonly close = vi.fn()
|
|
37
|
+
readonly run = vi.fn(async () => [])
|
|
38
|
+
readonly preload = vi.fn(() => ({ cleanup: () => {}, complete: Promise.resolve() }))
|
|
39
|
+
|
|
40
|
+
constructor() {
|
|
41
|
+
instances.push(this)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const instances: FakeZero[] = []
|
|
46
|
+
return { FakeZero, FakeConnectionState, instances }
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
vi.mock('@rocicorp/zero', async (importOriginal) => {
|
|
50
|
+
const actual = await importOriginal<typeof import('@rocicorp/zero')>()
|
|
51
|
+
return { ...actual, Zero: fakeZero.FakeZero }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
import { createZeroClient } from './createZeroClient'
|
|
55
|
+
|
|
56
|
+
declare global {
|
|
57
|
+
// eslint-disable-next-line no-var
|
|
58
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined
|
|
59
|
+
}
|
|
60
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
|
61
|
+
|
|
62
|
+
const noteTable = table('note').columns({ id: string(), body: string() }).primaryKey('id')
|
|
63
|
+
const schema = createSchema({ tables: [noteTable] })
|
|
64
|
+
|
|
65
|
+
const client = createZeroClient({
|
|
66
|
+
schema,
|
|
67
|
+
models: {},
|
|
68
|
+
groupedQueries: {},
|
|
69
|
+
instanceName: 'connection-test',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
let container: HTMLDivElement
|
|
73
|
+
let root: Root | null
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
fakeZero.instances.length = 0
|
|
77
|
+
container = document.createElement('div')
|
|
78
|
+
root = null
|
|
79
|
+
delete document.body.dataset.zeroState
|
|
80
|
+
delete document.body.dataset.zeroConnected
|
|
81
|
+
delete document.body.dataset.zeroReason
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
if (root) act(() => root?.unmount())
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
async function mount(extraProps: Record<string, unknown> = {}, userID = 'conn') {
|
|
89
|
+
root = createRoot(container)
|
|
90
|
+
await act(async () => {
|
|
91
|
+
root?.render(
|
|
92
|
+
<client.ProvideZero
|
|
93
|
+
cacheURL="http://127.0.0.1:7788/zero"
|
|
94
|
+
userID={userID}
|
|
95
|
+
{...extraProps}
|
|
96
|
+
>
|
|
97
|
+
<span>ok</span>
|
|
98
|
+
</client.ProvideZero>,
|
|
99
|
+
)
|
|
100
|
+
await Promise.resolve()
|
|
101
|
+
})
|
|
102
|
+
return fakeZero.instances.at(-1)!
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('refreshAuth reconnects in place on needs-auth, once per transition', async () => {
|
|
106
|
+
const refreshAuth = vi.fn(async () => 'fresh-token')
|
|
107
|
+
const instance = await mount({ refreshAuth }, 'conn-auth')
|
|
108
|
+
|
|
109
|
+
await act(async () => {
|
|
110
|
+
instance.connection.state.set({ name: 'needs-auth', reason: 'token expired' })
|
|
111
|
+
await Promise.resolve()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
expect(refreshAuth).toHaveBeenCalledTimes(1)
|
|
115
|
+
await act(async () => {
|
|
116
|
+
await Promise.resolve()
|
|
117
|
+
})
|
|
118
|
+
expect(instance.connection.connect).toHaveBeenCalledWith({ auth: 'fresh-token' })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('stale-poke error reconnects instead of surfacing a fatal error', async () => {
|
|
122
|
+
const events: Array<{ type: string }> = []
|
|
123
|
+
const off = client.zeroEvents.listen((event) => {
|
|
124
|
+
if (event) events.push(event)
|
|
125
|
+
})
|
|
126
|
+
const instance = await mount({}, 'conn-stale')
|
|
127
|
+
|
|
128
|
+
await act(async () => {
|
|
129
|
+
instance.connection.state.set({
|
|
130
|
+
name: 'error',
|
|
131
|
+
reason: 'Server returned unexpected base cookie during sync',
|
|
132
|
+
})
|
|
133
|
+
await Promise.resolve()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// reconnect issued, no auth arg (plain resume)
|
|
137
|
+
expect(instance.connection.connect).toHaveBeenCalledTimes(1)
|
|
138
|
+
expect(instance.connection.connect).toHaveBeenCalledWith()
|
|
139
|
+
// the stale-poke reason must NOT be emitted as an error event
|
|
140
|
+
expect(events.some((event) => event.type === 'error')).toBe(false)
|
|
141
|
+
off()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('connectionDataset mirrors connection state onto the body dataset', async () => {
|
|
145
|
+
const instance = await mount({ connectionDataset: true }, 'conn-dataset')
|
|
146
|
+
|
|
147
|
+
await act(async () => {
|
|
148
|
+
instance.connection.state.set({ name: 'connected' })
|
|
149
|
+
await Promise.resolve()
|
|
150
|
+
})
|
|
151
|
+
expect(document.body.dataset.zeroState).toBe('connected')
|
|
152
|
+
expect(document.body.dataset.zeroConnected).toBe('true')
|
|
153
|
+
expect(document.body.dataset.zeroCacheUrl).toBe('http://127.0.0.1:7788/zero')
|
|
154
|
+
|
|
155
|
+
await act(async () => {
|
|
156
|
+
instance.connection.state.set({ name: 'error', reason: 'boom' })
|
|
157
|
+
await Promise.resolve()
|
|
158
|
+
})
|
|
159
|
+
expect(document.body.dataset.zeroState).toBe('error')
|
|
160
|
+
expect(document.body.dataset.zeroConnected).toBeUndefined()
|
|
161
|
+
expect(document.body.dataset.zeroReason).toBe('boom')
|
|
162
|
+
})
|
|
@@ -102,3 +102,71 @@ test('zero.delete invalidates cached instance before a remount recovery', async
|
|
|
102
102
|
expect(fakeZero.instances).toHaveLength(2)
|
|
103
103
|
expect(fakeZero.instances[1]).not.toBe(first)
|
|
104
104
|
})
|
|
105
|
+
|
|
106
|
+
test('remint drops local state and reconstructs a fresh instance in place', async () => {
|
|
107
|
+
// unique userID so instanceKey misses the module-level cache from prior tests
|
|
108
|
+
// and a fresh instance is genuinely constructed here.
|
|
109
|
+
root = createRoot(container)
|
|
110
|
+
await act(async () => {
|
|
111
|
+
root?.render(
|
|
112
|
+
<client.ProvideZero cacheURL="http://127.0.0.1:7777/zero" userID="remint-test">
|
|
113
|
+
<span>ok</span>
|
|
114
|
+
</client.ProvideZero>,
|
|
115
|
+
)
|
|
116
|
+
await Promise.resolve()
|
|
117
|
+
})
|
|
118
|
+
const first = fakeZero.instances.at(-1)
|
|
119
|
+
expect(first).toBeDefined()
|
|
120
|
+
const countBefore = fakeZero.instances.length
|
|
121
|
+
|
|
122
|
+
let result: boolean | undefined
|
|
123
|
+
await act(async () => {
|
|
124
|
+
result = await client.remint()
|
|
125
|
+
// let the generation bump re-render and the rotate effect mint the fresh one
|
|
126
|
+
await Promise.resolve()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
expect(result).toBe(true)
|
|
130
|
+
// dropped the rejected store + closed it, then minted a genuinely new client
|
|
131
|
+
// without unmounting the provider (no page reload).
|
|
132
|
+
expect(first?.delete).toHaveBeenCalledTimes(1)
|
|
133
|
+
expect(fakeZero.instances.length).toBe(countBefore + 1)
|
|
134
|
+
expect(fakeZero.instances.at(-1)).not.toBe(first)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('remint with no provider mounted returns false without burning the guard budget', async () => {
|
|
138
|
+
// own client so the shared remint guard state is fresh for this assertion.
|
|
139
|
+
const isolated = createZeroClient({
|
|
140
|
+
schema,
|
|
141
|
+
models: {},
|
|
142
|
+
groupedQueries: {},
|
|
143
|
+
instanceName: 'remint-unmounted-test',
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// nothing mounted → remintControl.bump is null. these must NOT start the 12s
|
|
147
|
+
// cooldown or consume the attempt budget.
|
|
148
|
+
expect(await isolated.remint()).toBe(false)
|
|
149
|
+
expect(await isolated.remint()).toBe(false)
|
|
150
|
+
|
|
151
|
+
root = createRoot(container)
|
|
152
|
+
await act(async () => {
|
|
153
|
+
root?.render(
|
|
154
|
+
<isolated.ProvideZero
|
|
155
|
+
cacheURL="http://127.0.0.1:7777/zero"
|
|
156
|
+
userID="remint-unmounted"
|
|
157
|
+
>
|
|
158
|
+
<span>ok</span>
|
|
159
|
+
</isolated.ProvideZero>,
|
|
160
|
+
)
|
|
161
|
+
await Promise.resolve()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// a mounted remint immediately after is still allowed — proof the unmounted
|
|
165
|
+
// calls didn't burn the guard (old code set lastRemintAt before this check).
|
|
166
|
+
let result: boolean | undefined
|
|
167
|
+
await act(async () => {
|
|
168
|
+
result = await isolated.remint()
|
|
169
|
+
await Promise.resolve()
|
|
170
|
+
})
|
|
171
|
+
expect(result).toBe(true)
|
|
172
|
+
})
|