cabloy 5.1.207 → 5.1.209

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 (97) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +1 -0
  4. package/package.json +1 -1
  5. package/repo-agent-governance/managed-assets.json +9 -9
  6. package/repo-agent-governance/policies/repository.md +1 -1
  7. package/repo-agent-governance/skills/cabloy-workflow/SKILL.md +4 -2
  8. package/repo-agent-governance/skills/cabloy-workflow/evals/evals.json +13 -1
  9. package/repo-docs/backend/unit-testing.md +1 -1
  10. package/repo-docs/frontend/form-guide.md +17 -1
  11. package/repo-docs/frontend/model-architecture.md +4 -0
  12. package/repo-docs/frontend/model-state-guide.md +23 -2
  13. package/repo-docs/frontend/resource-entry-page-deep-dive.md +119 -0
  14. package/repo-docs/frontend/scripts.md +3 -3
  15. package/repo-docs/frontend/zova-form-under-the-hood.md +5 -0
  16. package/repo-docs/fullstack/parallel-worktree-environment.md +3 -3
  17. package/repo-docs/fullstack/quickstart.md +11 -1
  18. package/repo-docs/reference/repo-scripts.md +19 -5
  19. package/repo-e2e/specs/cabloy-basic.spec.ts +1577 -0
  20. package/vona/packages-vona/vona/package.json +1 -1
  21. package/vona/src/suite/a-home/modules/home-user/src/controller/passport.ts +1 -0
  22. package/vona/src/suite/a-home/modules/home-user/src/dto/passport.ts +4 -0
  23. package/vona/src/suite/a-home/modules/home-user/test/passport.test.ts +172 -0
  24. package/vona/src/suite/a-home/modules/home-user/test/role.test.ts +1 -0
  25. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  26. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  27. package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
  28. package/vona/src/suite-vendor/a-vona/modules/a-openapi/src/types/rest.ts +1 -0
  29. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  30. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  31. package/zova/packages-cli/cli/package.json +2 -2
  32. package/zova/packages-cli/cli-set-front/cli/templates/rest/render.ts +2 -0
  33. package/zova/packages-cli/cli-set-front/cli/templates/rest/rest.ts +9 -0
  34. package/zova/packages-cli/cli-set-front/package.json +1 -1
  35. package/zova/packages-zova/zova/package.json +2 -2
  36. package/zova/src/suite/a-home/modules/home-api/src/.metadata/index.ts +17 -17
  37. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/schemas.ts +68 -52
  38. package/zova/src/suite/a-home/modules/home-api/src/api/openapi/types.ts +524 -343
  39. package/zova/src/suite/a-home/modules/home-passport/src/model/passport.ts +45 -11
  40. package/zova/src/suite/a-home/modules/home-passport/test/lib/persistenceIdentity.test.ts +229 -0
  41. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/package.json +9 -1
  42. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/.metadata/index.ts +81 -5
  43. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/bean/bean.draftCoordinator.ts +158 -0
  44. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockForm/controller.tsx +11 -3
  45. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +438 -8
  46. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/config.ts +11 -0
  47. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/en-us.ts +9 -0
  48. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/config/locale/zh-cn.ts +9 -0
  49. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/lib/draftSchema.ts +649 -0
  50. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/model/draft.ts +59 -0
  51. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/monkey.ts +11 -0
  52. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/types/draft.ts +49 -0
  53. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftDate.test.ts +262 -0
  54. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLifecycle.test.ts +1073 -0
  55. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftLoader.mjs +13 -0
  56. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftModel.test.ts +572 -0
  57. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchema.test.ts +427 -0
  58. package/zova/src/suite/cabloy-basic/modules/basic-pageentry/test/lib/draftSchemaSafety.test.ts +200 -0
  59. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  60. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/page/entry/controller.tsx +6 -1
  61. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  62. package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
  63. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/form/controller.tsx +86 -1
  64. package/zova/src/suite-vendor/a-zova/modules/a-form/src/component/formField/controller.tsx +88 -13
  65. package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/schemaRefresh.ts +751 -0
  66. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefresh.test.ts +637 -0
  67. package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/schemaRefreshLifecycle.test.ts +994 -0
  68. package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
  69. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.persister.ts +331 -87
  70. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.query.ts +11 -6
  71. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useQuery.ts +3 -1
  72. package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useState.ts +17 -1
  73. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/index.ts +1 -0
  74. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/modelPersistence.ts +204 -0
  75. package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/persistenceQueue.ts +50 -0
  76. package/zova/src/suite-vendor/a-zova/modules/a-model/src/monkey.ts +5 -0
  77. package/zova/src/suite-vendor/a-zova/modules/a-model/src/service/storage.ts +47 -2
  78. package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +3 -0
  79. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelLoader.mjs +34 -0
  80. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/modelPersistence.test.ts +727 -0
  81. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceQueue.test.ts +137 -0
  82. package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/persistenceTransition.test.ts +129 -0
  83. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  84. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/monkey.ts +7 -5
  85. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.ts +2 -0
  86. package/zova/src/suite-vendor/a-zova/modules/a-router/package.json +1 -1
  87. package/zova/src/suite-vendor/a-zova/modules/a-router/src/bean/bean.router.ts +14 -0
  88. package/zova/src/suite-vendor/a-zova/modules/a-router/src/lib/routeDiscard.ts +26 -0
  89. package/zova/src/suite-vendor/a-zova/modules/a-router/src/service/routerGuards.ts +6 -3
  90. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/index.ts +1 -0
  91. package/zova/src/suite-vendor/a-zova/modules/a-router/src/types/routeDiscard.ts +24 -0
  92. package/zova/src/suite-vendor/a-zova/modules/a-router/test/lib/routeDiscard.test.ts +105 -0
  93. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/package.json +1 -1
  94. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/src/model/tabs.ts +202 -30
  95. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabs.test.ts +524 -0
  96. package/zova/src/suite-vendor/a-zova/modules/a-routertabs/test/lib/tabsLoader.mjs +35 -0
  97. package/zova/src/suite-vendor/a-zova/package.json +6 -6
@@ -0,0 +1,1073 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import { setImmediate as tick } from 'node:timers/promises';
5
+ import ts from 'typescript';
6
+ import { computed, effectScope, markRaw, nextTick, reactive, watch } from 'vue';
7
+
8
+ import type { IDraftEnvelope, IDraftIdentity } from '../../src/types/draft.js';
9
+
10
+ import {
11
+ createDraftSchema,
12
+ draftBaselineSignature,
13
+ projectDraftValues,
14
+ restoreDraftValues,
15
+ } from '../../src/lib/draftSchema.js';
16
+
17
+ function deferred<T = void>() {
18
+ let resolve!: (value: T) => void;
19
+ let reject!: (error: unknown) => void;
20
+ const promise = new Promise<T>((done, fail) => {
21
+ resolve = done;
22
+ reject = fail;
23
+ });
24
+ return { promise, resolve, reject };
25
+ }
26
+
27
+ interface Node {
28
+ type: unknown;
29
+ props: Record<string, any>;
30
+ children: any[];
31
+ }
32
+
33
+ function h(type: unknown, props: Record<string, any> | null, ...children: any[]): Node {
34
+ return { type, props: props ?? {}, children: children.flat(Infinity) };
35
+ }
36
+
37
+ // Adapt a-form's schemaRefreshLifecycle evaluator: execute the current controller,
38
+ // including its TSX, rather than copying the draft state machine into the test.
39
+ function evaluateController(bindings: Record<string, unknown>) {
40
+ const path = '../../src/component/blockPageEntry/controller.tsx';
41
+ const source = readFileSync(new URL(path, import.meta.url), 'utf8')
42
+ .replace(/^import[\s\S]*?;\n/gm, '')
43
+ .replace(/^\s*@(Controller|UseScope|Use)\([^\n]*\)\s*\n/gm, '');
44
+ const { outputText, diagnostics } = ts.transpileModule(source, {
45
+ fileName: path,
46
+ reportDiagnostics: true,
47
+ compilerOptions: {
48
+ target: ts.ScriptTarget.ES2022,
49
+ module: ts.ModuleKind.ESNext,
50
+ jsx: ts.JsxEmit.React,
51
+ jsxFactory: 'h',
52
+ },
53
+ });
54
+ assert.deepEqual(
55
+ diagnostics?.filter(item => item.category === ts.DiagnosticCategory.Error),
56
+ [],
57
+ );
58
+ const javascript = outputText.replace(
59
+ 'export class ControllerBlockPageEntry',
60
+ 'class ControllerBlockPageEntry',
61
+ );
62
+ return new Function(...Object.keys(bindings), `${javascript}\nreturn ControllerBlockPageEntry;`)(
63
+ ...Object.values(bindings),
64
+ );
65
+ }
66
+
67
+ const ControllerBlockPageEntry = evaluateController({
68
+ BeanControllerBase: class {},
69
+ createDraftSchema,
70
+ draftBaselineSignature,
71
+ projectDraftValues,
72
+ restoreDraftValues,
73
+ formMetaFromFormScene: (scene: string) => ({ formMode: 'edit', editMode: scene }),
74
+ $QueriesEnsureLoaded: async (...queries: (() => unknown)[]) => {
75
+ for (const query of queries) await query();
76
+ },
77
+ deepEqual: (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right),
78
+ deepExtend: (target: any, source: any) => Object.assign(target, structuredClone(source)),
79
+ isNil: (value: unknown) => value === undefined || value === null,
80
+ h,
81
+ ZButton: 'ZButton',
82
+ });
83
+
84
+ const baseline = {
85
+ id: 7,
86
+ name: 'Server name',
87
+ note: 'Server note',
88
+ server: 'Fresh readonly value',
89
+ password: 'Never persist',
90
+ };
91
+
92
+ const formSchema = {
93
+ type: 'object',
94
+ properties: {
95
+ id: { type: 'number', readOnly: true },
96
+ name: { type: 'string' },
97
+ note: { type: 'string' },
98
+ server: { type: 'string', readOnly: true },
99
+ password: { type: 'string' },
100
+ },
101
+ };
102
+
103
+ type Outcome = 'success' | 'failed' | 'throw' | 'stale';
104
+
105
+ function operation(outcome: Outcome = 'success') {
106
+ return { ...deferred(), entered: deferred(), outcome };
107
+ }
108
+
109
+ type Operation = ReturnType<typeof operation>;
110
+
111
+ function createRuntime() {
112
+ const entry: any = new ControllerBlockPageEntry();
113
+ const identity: IDraftIdentity = {
114
+ context: { scope: ['2', 'admin', '1'], epoch: 0 },
115
+ resource: 'training-student:student',
116
+ mode: 'edit',
117
+ id: '7',
118
+ path: '/resource/student/7/edit',
119
+ };
120
+ const key = JSON.stringify(identity);
121
+ const database = new Map<string, IDraftEnvelope>();
122
+ const owner: any = { snapshot: null, failed: false };
123
+ const plans = { open: [] as Operation[], save: [] as Operation[], remove: [] as Operation[] };
124
+ const calls = { identity: 0, open: 0, remove: 0, refetch: 0, bean: 0 };
125
+ const saves: { envelope: IDraftEnvelope; done: Promise<void> }[] = [];
126
+ const resets: { values: any; options: any }[] = [];
127
+ const pageMeta: any[] = [];
128
+ const listeners = new Set<(event: any, next: () => void) => void>();
129
+ let generation = 0;
130
+ let beanWait: Promise<void> | undefined;
131
+ let refetch: () => Promise<any> = async () => ({ data: structuredClone(baseline) });
132
+ const passport = { isAuthenticated: true, user: { id: 1, anonymous: false } };
133
+ const coordinator = {
134
+ identity() {
135
+ calls.identity++;
136
+ return identity;
137
+ },
138
+ async open() {
139
+ calls.open++;
140
+ const plan = plans.open.shift();
141
+ plan?.entered.resolve();
142
+ if (plan) await plan.promise;
143
+ if (plan?.outcome === 'throw') throw new Error('Open failed');
144
+ if (plan?.outcome === 'stale') return;
145
+ owner.snapshot = structuredClone(database.get(key) ?? null);
146
+ owner.failed = plan?.outcome === 'failed';
147
+ return { key, identity, generation, owner };
148
+ },
149
+ isCurrent(actualIdentity: IDraftIdentity) {
150
+ return (
151
+ passport.isAuthenticated && String(passport.user.id) === actualIdentity.context.scope[2]
152
+ );
153
+ },
154
+ current(editor: any) {
155
+ return editor.generation === generation && this.isCurrent(editor.identity);
156
+ },
157
+ save(editor: any, actualIdentity: IDraftIdentity, envelope: IDraftEnvelope) {
158
+ assert.equal(actualIdentity, identity);
159
+ assert.equal(this.current(editor), true);
160
+ const plan = plans.save.shift();
161
+ // ModelDraft updates its in-memory snapshot before persisting. Failure must
162
+ // not erase the old durable envelope, even though the owner has new data.
163
+ owner.snapshot = structuredClone(envelope);
164
+ const done = (async () => {
165
+ plan?.entered.resolve();
166
+ if (plan) await plan.promise;
167
+ owner.failed = plan?.outcome === 'failed' || plan?.outcome === 'throw';
168
+ if (plan?.outcome === 'throw') throw new Error('Save threw');
169
+ if (!owner.failed) database.set(key, structuredClone(envelope));
170
+ })();
171
+ saves.push({ envelope: structuredClone(envelope), done });
172
+ return done;
173
+ },
174
+ async remove(actualIdentity: IDraftIdentity) {
175
+ assert.equal(actualIdentity, identity);
176
+ calls.remove++;
177
+ ++generation;
178
+ const plan = plans.remove.shift();
179
+ plan?.entered.resolve();
180
+ if (plan) await plan.promise;
181
+ if (plan?.outcome === 'throw' || plan?.outcome === 'failed') {
182
+ owner.failed = true;
183
+ throw new Error('Remove failed');
184
+ }
185
+ database.delete(key);
186
+ owner.snapshot = null;
187
+ owner.failed = false;
188
+ },
189
+ };
190
+ entry.app = {
191
+ bean: {
192
+ async _getBean(name: string, cached: boolean) {
193
+ assert.equal(name, 'basic-pageentry.bean.draftCoordinator');
194
+ assert.equal(cached, true);
195
+ calls.bean++;
196
+ await beanWait;
197
+ return coordinator;
198
+ },
199
+ },
200
+ meta: {
201
+ locale: { current: 'en-us' },
202
+ event: {
203
+ on(name: string, listener: (event: any, next: () => void) => void) {
204
+ assert.equal(name, 'a-router:routeDiscard');
205
+ listeners.add(listener);
206
+ return () => listeners.delete(listener);
207
+ },
208
+ },
209
+ },
210
+ };
211
+ entry.scope = {
212
+ config: { draft: { enabled: true, resources: {}, maxAge: 86400000 } },
213
+ locale: Object.fromEntries(
214
+ [
215
+ 'DraftAvailable',
216
+ 'DraftSaved',
217
+ 'DraftRecover',
218
+ 'DraftDiscard',
219
+ 'DraftStorageFailed',
220
+ 'DraftIncompatible',
221
+ 'DraftRecoverFailed',
222
+ 'DraftReplaceConfirm',
223
+ 'DraftRetry',
224
+ 'EntryNotExist',
225
+ ].map(name => [name, () => name]),
226
+ ),
227
+ };
228
+ entry.$props = {
229
+ ...ControllerBlockPageEntry.$propsDefault,
230
+ resource: identity.resource,
231
+ id: 7,
232
+ formScene: 'edit',
233
+ };
234
+ entry.$passport = passport;
235
+ entry.$ssr = { isRuntimeSsrPreHydration: false };
236
+ entry.$pageRoute = { fullPath: identity.path };
237
+ entry.$router = {
238
+ resolve: (fullPath: string) => ({ path: fullPath.split('?')[0] }),
239
+ setPageMeta: (_route: any, meta: any) => pageMeta.push(structuredClone(meta)),
240
+ };
241
+ entry.$sdk = {
242
+ loadSchemaProperties(schema: any) {
243
+ return Object.entries(schema.properties ?? {}).map(([key, value]) => ({
244
+ ...(value as any),
245
+ key,
246
+ }));
247
+ },
248
+ getSchema: () => ({ data: undefined }),
249
+ };
250
+ entry.formMeta = { formMode: 'edit', editMode: 'edit', formScene: 'edit' };
251
+ entry.formSchema = structuredClone(formSchema);
252
+ entry.formData = structuredClone(baseline);
253
+ entry.formRef = {
254
+ formState: { values: structuredClone(baseline) },
255
+ reset(values: any, options: any) {
256
+ resets.push({ values: structuredClone(values), options });
257
+ this.formState.values = structuredClone(values);
258
+ },
259
+ };
260
+ entry.$$modelResource = {
261
+ view() {
262
+ return {
263
+ refetch(options: any) {
264
+ assert.deepEqual(options, { throwOnError: true });
265
+ calls.refetch++;
266
+ return refetch();
267
+ },
268
+ };
269
+ },
270
+ };
271
+ entry.$performCommand = async () => true;
272
+ entry._setEditorBaseline(baseline);
273
+ pageMeta.length = 0;
274
+ return {
275
+ entry,
276
+ coordinator,
277
+ identity,
278
+ database,
279
+ owner,
280
+ calls,
281
+ saves,
282
+ resets,
283
+ pageMeta,
284
+ passport,
285
+ candidate(values: any = { name: 'Old draft', note: 'Old draft note' }) {
286
+ const schema = entry._getDraftSchema();
287
+ const envelope: IDraftEnvelope = {
288
+ version: 1,
289
+ savedAt: Date.now(),
290
+ schemaSignature: schema.signature,
291
+ baselineSignature: draftBaselineSignature(schema, baseline),
292
+ projection: projectDraftValues(schema, values, baseline),
293
+ };
294
+ database.set(key, structuredClone(envelope));
295
+ return envelope;
296
+ },
297
+ durable() {
298
+ return database.get(key);
299
+ },
300
+ plan(kind: keyof typeof plans, outcome: Outcome = 'success') {
301
+ const plan = operation(outcome);
302
+ plans[kind].push(plan);
303
+ return plan;
304
+ },
305
+ waitForBean(promise: Promise<void>) {
306
+ beanWait = promise;
307
+ },
308
+ refetchWith(callback: () => Promise<any>) {
309
+ refetch = callback;
310
+ },
311
+ change(values: any) {
312
+ entry.formRef.formState.values = structuredClone(values);
313
+ entry.formChanged(entry.formRef.formState.values);
314
+ },
315
+ discardRoute() {
316
+ for (const listener of [...listeners]) {
317
+ listener(
318
+ { router: entry.$router, items: [{ fullPath: `${identity.path}?tab=details` }] },
319
+ () => {},
320
+ );
321
+ }
322
+ },
323
+ renderDraft() {
324
+ return [entry._renderDraft(), entry._renderDraftStorage?.()];
325
+ },
326
+ dispose() {
327
+ entry.__dispose__();
328
+ },
329
+ };
330
+ }
331
+
332
+ type Runtime = ReturnType<typeof createRuntime>;
333
+
334
+ async function withRuntime(run: (runtime: Runtime) => Promise<void>) {
335
+ const previousClient = process.env.CLIENT;
336
+ Object.assign(process.env, { CLIENT: 'true' });
337
+ let runtime: Runtime | undefined;
338
+ try {
339
+ runtime = createRuntime();
340
+ await run(runtime);
341
+ } finally {
342
+ runtime?.dispose();
343
+ if (previousClient === undefined) Reflect.deleteProperty(process.env, 'CLIENT');
344
+ else Object.assign(process.env, { CLIENT: previousClient });
345
+ }
346
+ }
347
+
348
+ function nodes(value: any): Node[] {
349
+ if (Array.isArray(value)) return value.flatMap(nodes);
350
+ if (!value || typeof value !== 'object') return [];
351
+ return [value, ...nodes(value.children)];
352
+ }
353
+
354
+ function testIds(value: any) {
355
+ return nodes(value)
356
+ .map(node => node.props['data-testid'])
357
+ .filter(Boolean);
358
+ }
359
+
360
+ function assertNoReset(runtime: Runtime, values: any) {
361
+ assert.deepEqual(runtime.resets, []);
362
+ assert.deepEqual(runtime.entry.formRef.formState.values, values);
363
+ }
364
+
365
+ async function startCandidate(runtime: Runtime) {
366
+ const candidate = runtime.candidate();
367
+ await runtime.entry._startDraft();
368
+ assert.deepEqual(runtime.entry.draftCandidate, candidate);
369
+ return candidate;
370
+ }
371
+
372
+ for (const earlyInput of [false, true]) {
373
+ test(
374
+ `delayed edit schema starts discovery after mounted readiness${earlyInput ? ' without replacing early input' : ' exactly once'}`,
375
+ { concurrency: false },
376
+ async () => {
377
+ await withRuntime(async runtime => {
378
+ const candidate = runtime.candidate();
379
+ const scope = effectScope();
380
+ markRaw(runtime.coordinator);
381
+ markRaw(runtime.identity);
382
+ markRaw(runtime.entry.formRef);
383
+ const entry = reactive(runtime.entry);
384
+ const readiness = reactive({ schema: undefined as typeof formSchema | undefined });
385
+ const hydrated: (() => void)[] = [];
386
+ const model = runtime.entry.$$modelResource;
387
+ Object.assign(model, {
388
+ getFormSchema: () => readiness.schema,
389
+ getFormData: () => structuredClone(baseline),
390
+ getFormApiSchemas: () => undefined,
391
+ });
392
+ entry.bean = { _getBeanSelector: async () => model };
393
+ entry._prepareJsx = () => {};
394
+ entry.$computed = (getter: () => unknown) => scope.run(() => computed(getter));
395
+ entry.$watch = (...args: any[]) => scope.run(() => (watch as any)(...args));
396
+ entry.$ssr = {
397
+ isRuntimeSsrPreHydration: true,
398
+ handleDirectOrOnHydrated(callback: () => void) {
399
+ if (this.isRuntimeSsrPreHydration) hydrated.push(callback);
400
+ else callback();
401
+ },
402
+ };
403
+ try {
404
+ await entry.__init__();
405
+ entry.formReady(entry.formRef);
406
+ assert.equal(runtime.calls.open, 0);
407
+ entry.$ssr.isRuntimeSsrPreHydration = false;
408
+ hydrated.forEach(callback => callback());
409
+ await tick();
410
+ assert.equal(runtime.calls.open, 0);
411
+ assert.equal(entry._draftStarted, false);
412
+ if (earlyInput) {
413
+ entry.formRef.formState.values = { ...baseline, name: 'New early input' };
414
+ entry.formChanged(entry.formRef.formState.values);
415
+ }
416
+ readiness.schema = structuredClone(formSchema);
417
+ await nextTick();
418
+ await tick();
419
+ assert.equal(runtime.calls.open, 1);
420
+ if (earlyInput) {
421
+ assert.equal(entry.draftCandidate, undefined);
422
+ assert.equal(runtime.durable()?.projection.values.name, 'New early input');
423
+ } else {
424
+ assert.deepEqual(entry.draftCandidate, candidate);
425
+ await entry.recoverDraft();
426
+ assert.equal(entry.draftCandidate, undefined);
427
+ assert.equal(entry.formRef.formState.values.name, 'Old draft');
428
+ }
429
+ // Schema gaps or another form-ready callback must not rediscover active work.
430
+ readiness.schema = undefined;
431
+ await nextTick();
432
+ readiness.schema = structuredClone(formSchema);
433
+ await nextTick();
434
+ entry.formReady(entry.formRef);
435
+ await tick();
436
+ assert.equal(runtime.calls.open, 1);
437
+ assert.equal(entry.draftCandidate, undefined);
438
+ } finally {
439
+ entry.__dispose__();
440
+ scope.stop();
441
+ }
442
+ });
443
+ },
444
+ );
445
+ }
446
+
447
+ test(
448
+ 'first input synchronously dismisses the candidate and saves one complete replacement without remove',
449
+ { concurrency: false },
450
+ async () => {
451
+ await withRuntime(async runtime => {
452
+ await startCandidate(runtime);
453
+ const save = runtime.plan('save');
454
+ runtime.change({ name: 'New input', password: 'Do not persist', unknown: 'Not declared' });
455
+ assert.equal(runtime.entry.draftCandidate, undefined);
456
+ assert.equal(runtime.entry._dirty, true);
457
+ assert.equal(runtime.entry._draftInputRevision, 1);
458
+ assert.equal(runtime.calls.remove, 0);
459
+ assert.equal(runtime.calls.refetch, 0);
460
+ assert.deepEqual(runtime.saves[0].envelope.projection.values, { name: 'New input' });
461
+ assert.equal(runtime.durable()!.projection.values.name, 'Old draft');
462
+ save.resolve();
463
+ await tick();
464
+ assert.deepEqual(runtime.durable()!.projection.values, { name: 'New input' });
465
+ assert.equal(runtime.entry.draftActive, true);
466
+ assert.equal(runtime.entry.draftError, undefined);
467
+ assertNoReset(runtime, {
468
+ name: 'New input',
469
+ password: 'Do not persist',
470
+ unknown: 'Not declared',
471
+ });
472
+ });
473
+ },
474
+ );
475
+
476
+ test(
477
+ 'input during slow coordinator discovery or open wins over the late saved candidate',
478
+ { concurrency: false },
479
+ async () => {
480
+ for (const delay of ['bean', 'open'] as const) {
481
+ await withRuntime(async runtime => {
482
+ runtime.candidate();
483
+ const gate = delay === 'bean' ? deferred() : runtime.plan('open');
484
+ if (delay === 'bean') runtime.waitForBean(gate.promise);
485
+ const started = runtime.entry._startDraft();
486
+ if (delay === 'open') await (gate as Operation).entered.promise;
487
+ runtime.change({ name: 'Input during discovery' });
488
+ assert.equal(runtime.entry.draftCandidate, undefined);
489
+ gate.resolve();
490
+ await started;
491
+ assert.equal(runtime.entry.draftCandidate, undefined);
492
+ assert.equal(runtime.calls.remove, 0);
493
+ assert.deepEqual(runtime.durable()!.projection.values, { name: 'Input during discovery' });
494
+ assertNoReset(runtime, { name: 'Input during discovery' });
495
+ });
496
+ }
497
+ },
498
+ );
499
+
500
+ test(
501
+ 'recovery uses real schema signatures, projection and restore over a fresh server baseline',
502
+ { concurrency: false },
503
+ async () => {
504
+ await withRuntime(async runtime => {
505
+ const candidate = await startCandidate(runtime);
506
+ const fresh = {
507
+ ...baseline,
508
+ server: 'Refetched readonly value',
509
+ password: 'Refetched secret',
510
+ };
511
+ runtime.refetchWith(async () => ({ data: fresh }));
512
+ const expected = restoreDraftValues(
513
+ runtime.entry._getDraftSchema(),
514
+ candidate.projection,
515
+ fresh,
516
+ );
517
+ await runtime.entry.recoverDraft();
518
+ assert.equal(runtime.calls.refetch, 1);
519
+ assert.deepEqual(runtime.resets, [
520
+ { values: expected, options: { keepDefaultValues: true } },
521
+ ]);
522
+ assert.equal(runtime.entry.formRef.formState.values.server, 'Refetched readonly value');
523
+ assert.equal(runtime.entry.formRef.formState.values.name, 'Old draft');
524
+ assert.equal(runtime.entry.draftCandidate, undefined);
525
+ assert.equal(runtime.entry.draftActive, true);
526
+ assert.equal(runtime.entry._dirty, true);
527
+ assert.equal(runtime.pageMeta.at(-1).pageDirty, true);
528
+ assert.equal(runtime.calls.remove, 0);
529
+ });
530
+ },
531
+ );
532
+
533
+ test(
534
+ 'recovery rejects changed baseline or schema signatures without resetting the form',
535
+ { concurrency: false },
536
+ async () => {
537
+ for (const mismatch of ['baseline', 'schema'] as const) {
538
+ await withRuntime(async runtime => {
539
+ await startCandidate(runtime);
540
+ if (mismatch === 'baseline') {
541
+ runtime.refetchWith(async () => ({ data: { ...baseline, name: 'Changed server name' } }));
542
+ } else {
543
+ runtime.entry.formSchema.properties.note.maxLength = 2;
544
+ }
545
+ await runtime.entry.recoverDraft();
546
+ assert.equal(runtime.entry.draftError, 'incompatible');
547
+ assert.ok(runtime.entry.draftCandidate);
548
+ assertNoReset(runtime, baseline);
549
+ });
550
+ }
551
+ },
552
+ );
553
+
554
+ test(
555
+ 'new input invalidates pending recovery refetch success and cannot be reset by its completion',
556
+ { concurrency: false },
557
+ async () => {
558
+ await withRuntime(async runtime => {
559
+ await startCandidate(runtime);
560
+ const result = deferred<any>();
561
+ runtime.refetchWith(() => result.promise);
562
+ const recovery = runtime.entry.recoverDraft();
563
+ assert.equal(runtime.calls.refetch, 1);
564
+ runtime.change({ name: 'Typed during refetch' });
565
+ await tick();
566
+ const metaCount = runtime.pageMeta.length;
567
+ result.resolve({ data: structuredClone(baseline) });
568
+ await recovery;
569
+ assertNoReset(runtime, { name: 'Typed during refetch' });
570
+ assert.equal(runtime.pageMeta.length, metaCount);
571
+ assert.equal(runtime.entry.draftCandidate, undefined);
572
+ assert.equal(runtime.entry.draftError, undefined);
573
+ assert.deepEqual(runtime.entry._baseline, baseline);
574
+ assert.deepEqual(runtime.durable()!.projection.values, { name: 'Typed during refetch' });
575
+ });
576
+ },
577
+ );
578
+
579
+ test(
580
+ 'new input invalidates pending recovery errors and retains the latest storage error',
581
+ { concurrency: false },
582
+ async () => {
583
+ for (const error of [new Error('Offline'), { code: 403 }, { response: { status: 404 } }]) {
584
+ await withRuntime(async runtime => {
585
+ await startCandidate(runtime);
586
+ const result = deferred<any>();
587
+ runtime.refetchWith(() => result.promise);
588
+ const recovery = runtime.entry.recoverDraft();
589
+ const save = runtime.plan('save', 'failed');
590
+ runtime.change({ name: 'Current input' });
591
+ save.resolve();
592
+ await tick();
593
+ assert.equal(runtime.entry.draftError, 'storage');
594
+ result.reject(error);
595
+ await recovery;
596
+ assert.equal(runtime.entry.draftError, 'storage');
597
+ assert.equal(runtime.entry.draftCandidate, undefined);
598
+ assertNoReset(runtime, { name: 'Current input' });
599
+ });
600
+ }
601
+ },
602
+ );
603
+
604
+ test(
605
+ 'current recovery failures remain visible and distinguish incompatible from recover errors',
606
+ { concurrency: false },
607
+ async () => {
608
+ for (const [error, expected] of [
609
+ [new Error('Offline'), 'recover'],
610
+ [Object.assign(new Error('Forbidden'), { code: 403 }), 'incompatible'],
611
+ ] as const) {
612
+ await withRuntime(async runtime => {
613
+ await startCandidate(runtime);
614
+ runtime.refetchWith(() => Promise.reject(error));
615
+ await runtime.entry.recoverDraft();
616
+ assert.equal(runtime.entry.draftError, expected);
617
+ assert.ok(runtime.entry.draftCandidate);
618
+ assertNoReset(runtime, baseline);
619
+ });
620
+ }
621
+ },
622
+ );
623
+
624
+ test(
625
+ 'input while replacement confirmation is pending prevents recovery refetch and reset',
626
+ { concurrency: false },
627
+ async () => {
628
+ await withRuntime(async runtime => {
629
+ await startCandidate(runtime);
630
+ // Exercise the supported confirmation branch without replacing controller methods.
631
+ runtime.entry._dirty = true;
632
+ const confirmed = deferred<boolean>();
633
+ runtime.entry.$performCommand = () => confirmed.promise;
634
+ const recovery = runtime.entry.recoverDraft();
635
+ runtime.change({ name: 'Input during confirmation' });
636
+ confirmed.resolve(true);
637
+ await recovery;
638
+ await tick();
639
+ assert.equal(runtime.calls.refetch, 0);
640
+ assertNoReset(runtime, { name: 'Input during confirmation' });
641
+ assert.equal(runtime.entry.draftCandidate, undefined);
642
+ });
643
+ },
644
+ );
645
+
646
+ test(
647
+ 'active drafts render no banner or discard toolbar, while candidates still offer recovery',
648
+ { concurrency: false },
649
+ async () => {
650
+ await withRuntime(async runtime => {
651
+ await startCandidate(runtime);
652
+ assert.ok(testIds(runtime.entry._renderDraft()).includes('resource-draft'));
653
+ assert.ok(
654
+ nodes(runtime.entry._renderDraft()).some(
655
+ node => typeof node.props.onPerform === 'function',
656
+ ),
657
+ );
658
+ runtime.change({ name: 'Active input' });
659
+ await tick();
660
+ assert.equal(runtime.entry.draftActive, true);
661
+ assert.equal(runtime.entry._renderDraft(), undefined);
662
+ assert.deepEqual(testIds(runtime.renderDraft()), []);
663
+ });
664
+ },
665
+ );
666
+
667
+ test(
668
+ 'active storage failures use a separate resource-draft-storage indicator and retry saves latest values',
669
+ { concurrency: false },
670
+ async () => {
671
+ for (const outcome of ['failed', 'throw'] as const) {
672
+ await withRuntime(async runtime => {
673
+ const old = await startCandidate(runtime);
674
+ const failed = runtime.plan('save', outcome);
675
+ runtime.change({ name: 'Failed replacement' });
676
+ failed.resolve();
677
+ await tick();
678
+ assert.equal(runtime.entry.draftCandidate, undefined);
679
+ assert.equal(runtime.entry.draftError, 'storage');
680
+ assert.deepEqual(runtime.durable(), old);
681
+ assert.equal(runtime.entry._renderDraft(), undefined);
682
+ assert.deepEqual(testIds(runtime.renderDraft()), ['resource-draft-storage']);
683
+ // Retry reads the current form, not the failed envelope or old candidate.
684
+ runtime.entry.formRef.formState.values = { name: 'Latest retry value', note: undefined };
685
+ await runtime.entry.retryDraftStorage();
686
+ assert.deepEqual(runtime.durable()!.projection.values, { name: 'Latest retry value' });
687
+ assert.deepEqual(runtime.durable()!.projection.undefinedPaths, ['["note"]']);
688
+ assert.equal(runtime.entry.draftError, undefined);
689
+ assert.equal(runtime.entry.draftActive, true);
690
+ assert.equal(runtime.calls.remove, 0);
691
+ assert.deepEqual(testIds(runtime.renderDraft()), []);
692
+ });
693
+ }
694
+ },
695
+ );
696
+
697
+ test(
698
+ 'a failed initial read retries discovery without replacing its candidate with baseline form data',
699
+ { concurrency: false },
700
+ async () => {
701
+ await withRuntime(async runtime => {
702
+ const old = runtime.candidate();
703
+ const read = runtime.plan('open', 'failed');
704
+ const started = runtime.entry._startDraft();
705
+ await read.entered.promise;
706
+ read.resolve();
707
+ await started;
708
+ assert.equal(runtime.entry.draftError, 'storage');
709
+ assert.deepEqual(runtime.entry.draftCandidate, old);
710
+ await runtime.entry.retryDraftStorage();
711
+ assert.deepEqual(runtime.entry.draftCandidate, old);
712
+ assert.equal(runtime.entry.draftError, undefined);
713
+ assert.equal(runtime.saves.length, 0);
714
+ assert.equal(runtime.calls.remove, 0);
715
+ assert.deepEqual(runtime.durable(), old);
716
+ });
717
+ },
718
+ );
719
+
720
+ test(
721
+ 'internal active discard resets the baseline but has no active draft toolbar',
722
+ { concurrency: false },
723
+ async () => {
724
+ await withRuntime(async runtime => {
725
+ await runtime.entry._startDraft();
726
+ runtime.change({ name: 'Active changes' });
727
+ await tick();
728
+ assert.equal(runtime.entry._renderDraft(), undefined);
729
+ await runtime.entry.discardDraft();
730
+ assert.deepEqual(runtime.resets, [{ values: baseline, options: undefined }]);
731
+ assert.equal(runtime.entry._dirty, false);
732
+ assert.equal(runtime.entry.draftActive, false);
733
+ assert.equal(runtime.entry.draftCandidate, undefined);
734
+ assert.equal(runtime.entry._draftRemovalPending, false);
735
+ assert.equal(runtime.durable(), undefined);
736
+ assert.equal(runtime.pageMeta.at(-1).pageDirty, false);
737
+ });
738
+ },
739
+ );
740
+
741
+ test(
742
+ 'discarding an untouched candidate never resets the live baseline',
743
+ { concurrency: false },
744
+ async () => {
745
+ await withRuntime(async runtime => {
746
+ await startCandidate(runtime);
747
+ await runtime.entry.discardDraft();
748
+ assertNoReset(runtime, baseline);
749
+ assert.equal(runtime.entry._dirty, false);
750
+ assert.equal(runtime.durable(), undefined);
751
+ });
752
+ },
753
+ );
754
+
755
+ test(
756
+ 'input during active removal or editor reopen is not reset and is saved after reopen',
757
+ { concurrency: false },
758
+ async () => {
759
+ for (const phase of ['remove', 'open'] as const) {
760
+ await withRuntime(async runtime => {
761
+ await runtime.entry._startDraft();
762
+ runtime.change({ name: 'Before discard' });
763
+ await tick();
764
+ const wait = runtime.plan(phase);
765
+ const discarded = runtime.entry.discardDraft();
766
+ await wait.entered.promise;
767
+ runtime.change({ name: `Input during ${phase}` });
768
+ assert.equal(runtime.entry._draftRemovalPending, true);
769
+ wait.resolve();
770
+ await discarded;
771
+ assertNoReset(runtime, { name: `Input during ${phase}` });
772
+ assert.equal(runtime.entry._dirty, true);
773
+ assert.equal(runtime.entry.draftActive, true);
774
+ assert.deepEqual(runtime.durable()!.projection.values, { name: `Input during ${phase}` });
775
+ assert.equal(runtime.pageMeta.at(-1).pageDirty, true);
776
+ });
777
+ }
778
+ },
779
+ );
780
+
781
+ test(
782
+ 'stale reopen, route-session invalidation and disposal prevent an active discard reset',
783
+ { concurrency: false },
784
+ async () => {
785
+ for (const invalidate of ['stale', 'session', 'dispose'] as const) {
786
+ await withRuntime(async runtime => {
787
+ await runtime.entry._startDraft();
788
+ runtime.change({ name: 'Keep live state' });
789
+ await tick();
790
+ const reopen = runtime.plan('open', invalidate === 'stale' ? 'stale' : 'success');
791
+ const discarded = runtime.entry.discardDraft();
792
+ await reopen.entered.promise;
793
+ if (invalidate === 'session') runtime.discardRoute();
794
+ if (invalidate === 'dispose') runtime.dispose();
795
+ const metaCount = runtime.pageMeta.length;
796
+ reopen.resolve();
797
+ await discarded;
798
+ assertNoReset(runtime, { name: 'Keep live state' });
799
+ assert.equal(runtime.pageMeta.length, metaCount);
800
+ });
801
+ }
802
+ },
803
+ );
804
+
805
+ test(
806
+ 'failed removal keeps durable data and retry removal still protects intervening input',
807
+ { concurrency: false },
808
+ async () => {
809
+ await withRuntime(async runtime => {
810
+ await runtime.entry._startDraft();
811
+ runtime.change({ name: 'Before failed discard' });
812
+ await tick();
813
+ const old = structuredClone(runtime.durable());
814
+ const removal = runtime.plan('remove', 'throw');
815
+ const discarded = runtime.entry.discardDraft();
816
+ removal.resolve();
817
+ await discarded;
818
+ assert.equal(runtime.entry.draftError, 'storage');
819
+ assert.equal(runtime.entry._draftRemovalPending, true);
820
+ assert.deepEqual(runtime.durable(), old);
821
+ assertNoReset(runtime, { name: 'Before failed discard' });
822
+ const retryRemoval = runtime.plan('remove');
823
+ const retry = runtime.entry.retryDraftStorage();
824
+ await retryRemoval.entered.promise;
825
+ runtime.change({ name: 'During retry removal' });
826
+ retryRemoval.resolve();
827
+ await retry;
828
+ assertNoReset(runtime, { name: 'During retry removal' });
829
+ assert.equal(runtime.entry.draftError, undefined);
830
+ assert.equal(runtime.entry._draftRemovalPending, false);
831
+ assert.deepEqual(runtime.durable()!.projection.values, { name: 'During retry removal' });
832
+ });
833
+ },
834
+ );
835
+
836
+ test(
837
+ 'retrying failed removal retains the original input revision and never resets intervening input',
838
+ { concurrency: false },
839
+ async () => {
840
+ await withRuntime(async runtime => {
841
+ await runtime.entry._startDraft();
842
+ runtime.change({ name: 'Before failed discard' });
843
+ await tick();
844
+ const removal = runtime.plan('remove', 'throw');
845
+ const discarded = runtime.entry.discardDraft();
846
+ removal.resolve();
847
+ await discarded;
848
+ assert.equal(runtime.entry._draftRemovalPending, true);
849
+ runtime.change({ name: 'After failed discard, before retry' });
850
+ await runtime.entry.retryDraftStorage();
851
+ assertNoReset(runtime, { name: 'After failed discard, before retry' });
852
+ assert.equal(runtime.entry._dirty, true);
853
+ assert.equal(runtime.entry._draftRemovalPending, false);
854
+ assert.deepEqual(runtime.durable()!.projection.values, {
855
+ name: 'After failed discard, before retry',
856
+ });
857
+ });
858
+ },
859
+ );
860
+
861
+ test(
862
+ 'schemaRefreshed is not input takeover and does not invalidate candidate recovery',
863
+ { concurrency: false },
864
+ async () => {
865
+ await withRuntime(async runtime => {
866
+ const candidate = await startCandidate(runtime);
867
+ runtime.entry._dirty = true;
868
+ const result = deferred<any>();
869
+ runtime.refetchWith(() => result.promise);
870
+ const recovery = runtime.entry.recoverDraft();
871
+ await tick();
872
+ assert.equal(runtime.calls.refetch, 1);
873
+ const inputRevision = runtime.entry._draftInputRevision;
874
+ const recoveryRevision = runtime.entry._draftRecoveryRevision;
875
+ runtime.entry.formSchema.properties.name.title = 'Translated name';
876
+ runtime.entry.formSchemaRefreshed(runtime.entry.formRef.formState.values);
877
+ assert.equal(runtime.entry._draftInputRevision, inputRevision);
878
+ assert.equal(runtime.entry._draftRecoveryRevision, recoveryRevision);
879
+ assert.deepEqual(runtime.entry.draftCandidate, candidate);
880
+ assert.equal(runtime.saves.length, 0);
881
+ result.resolve({ data: structuredClone(baseline) });
882
+ await recovery;
883
+ assert.equal(runtime.resets.length, 1);
884
+ assert.equal(runtime.entry.draftCandidate, undefined);
885
+ runtime.entry.formSchemaRefreshed(runtime.entry.formRef.formState.values);
886
+ await tick();
887
+ assert.equal(runtime.entry._draftInputRevision, inputRevision);
888
+ assert.equal(runtime.saves.length, 1);
889
+ assert.equal(runtime.entry.draftActive, true);
890
+ assert.equal(runtime.entry._dirty, true);
891
+ });
892
+ },
893
+ );
894
+
895
+ test(
896
+ 'schemaRefreshed during slow initial open leaves the candidate available',
897
+ { concurrency: false },
898
+ async () => {
899
+ await withRuntime(async runtime => {
900
+ const candidate = runtime.candidate();
901
+ const open = runtime.plan('open');
902
+ const started = runtime.entry._startDraft();
903
+ await open.entered.promise;
904
+ runtime.entry.formSchemaRefreshed(runtime.entry.formRef.formState.values);
905
+ open.resolve();
906
+ await started;
907
+ assert.deepEqual(runtime.entry.draftCandidate, candidate);
908
+ assert.equal(runtime.entry._draftInputRevision, 0);
909
+ assert.equal(runtime.saves.length, 0);
910
+ assert.equal(runtime.calls.remove, 0);
911
+ });
912
+ },
913
+ );
914
+
915
+ test(
916
+ 'overlapping saves ignore older failed or thrown completions after current success',
917
+ { concurrency: false },
918
+ async () => {
919
+ for (const outcome of ['failed', 'throw'] as const) {
920
+ await withRuntime(async runtime => {
921
+ await runtime.entry._startDraft();
922
+ const old = runtime.plan('save', outcome);
923
+ runtime.change({ name: 'Old pending input' });
924
+ runtime.change({ name: 'Current successful input' });
925
+ await tick();
926
+ assert.equal(runtime.entry.draftError, undefined);
927
+ assert.equal(runtime.entry.draftActive, true);
928
+ old.resolve();
929
+ await tick();
930
+ assert.equal(runtime.entry.draftError, undefined);
931
+ assert.equal(runtime.entry.draftActive, true);
932
+ assert.deepEqual(runtime.durable()!.projection.values, {
933
+ name: 'Current successful input',
934
+ });
935
+ assertNoReset(runtime, { name: 'Current successful input' });
936
+ });
937
+ }
938
+ },
939
+ );
940
+
941
+ test(
942
+ 'an older successful save cannot clear the current failed save error',
943
+ { concurrency: false },
944
+ async () => {
945
+ await withRuntime(async runtime => {
946
+ await runtime.entry._startDraft();
947
+ const old = runtime.plan('save');
948
+ runtime.change({ name: 'Old pending input' });
949
+ const latest = runtime.plan('save', 'failed');
950
+ runtime.change({ name: 'Current failed input' });
951
+ latest.resolve();
952
+ await tick();
953
+ assert.equal(runtime.entry.draftError, 'storage');
954
+ old.resolve();
955
+ await tick();
956
+ assert.equal(runtime.entry.draftError, 'storage');
957
+ await runtime.entry.retryDraftStorage();
958
+ assert.equal(runtime.entry.draftError, undefined);
959
+ assert.deepEqual(runtime.durable()!.projection.values, { name: 'Current failed input' });
960
+ });
961
+ },
962
+ );
963
+
964
+ test(
965
+ 'disposal or route-session invalidation suppresses late save errors',
966
+ { concurrency: false },
967
+ async () => {
968
+ for (const invalidate of ['session', 'dispose'] as const) {
969
+ await withRuntime(async runtime => {
970
+ await runtime.entry._startDraft();
971
+ const save = runtime.plan('save', 'throw');
972
+ runtime.change({ name: 'Before invalidation' });
973
+ if (invalidate === 'session') runtime.discardRoute();
974
+ else runtime.dispose();
975
+ const error = runtime.entry.draftError;
976
+ save.resolve();
977
+ await tick();
978
+ assert.equal(runtime.entry.draftError, error);
979
+ assertNoReset(runtime, { name: 'Before invalidation' });
980
+ });
981
+ }
982
+ },
983
+ );
984
+
985
+ test(
986
+ 'disposal or route-session invalidation independently suppresses pending recovery success and error',
987
+ { concurrency: false },
988
+ async () => {
989
+ for (const invalidate of ['session', 'dispose'] as const) {
990
+ for (const outcome of ['success', 'throw'] as const) {
991
+ await withRuntime(async runtime => {
992
+ await startCandidate(runtime);
993
+ const result = deferred<any>();
994
+ runtime.refetchWith(() => result.promise);
995
+ const recovery = runtime.entry.recoverDraft();
996
+ assert.equal(runtime.calls.refetch, 1);
997
+ if (invalidate === 'session') runtime.discardRoute();
998
+ else runtime.dispose();
999
+ const error = runtime.entry.draftError;
1000
+ const metaCount = runtime.pageMeta.length;
1001
+ if (outcome === 'success') result.resolve({ data: structuredClone(baseline) });
1002
+ else result.reject(new Error('Late recovery failure'));
1003
+ await recovery;
1004
+ assert.equal(runtime.entry.draftError, error);
1005
+ assert.equal(runtime.pageMeta.length, metaCount);
1006
+ assertNoReset(runtime, baseline);
1007
+ });
1008
+ }
1009
+ }
1010
+ },
1011
+ );
1012
+
1013
+ test(
1014
+ 'newer recovery invalidates an overlapping older recovery completion',
1015
+ { concurrency: false },
1016
+ async () => {
1017
+ await withRuntime(async runtime => {
1018
+ await startCandidate(runtime);
1019
+ const first = deferred<any>();
1020
+ const second = deferred<any>();
1021
+ let attempt = 0;
1022
+ runtime.refetchWith(() => (++attempt === 1 ? first.promise : second.promise));
1023
+ const oldRecovery = runtime.entry.recoverDraft();
1024
+ const currentRecovery = runtime.entry.recoverDraft();
1025
+ second.reject(new Error('Current recovery failed'));
1026
+ await currentRecovery;
1027
+ assert.equal(runtime.entry.draftError, 'recover');
1028
+ first.resolve({ data: structuredClone(baseline) });
1029
+ await oldRecovery;
1030
+ assert.equal(runtime.entry.draftError, 'recover');
1031
+ assertNoReset(runtime, baseline);
1032
+ });
1033
+ },
1034
+ );
1035
+
1036
+ test(
1037
+ 'SSR and prehydration render no candidate, active banner or storage indicator',
1038
+ { concurrency: false },
1039
+ async () => {
1040
+ await withRuntime(async runtime => {
1041
+ await startCandidate(runtime);
1042
+ for (const state of ['candidate', 'active', 'storage'] as const) {
1043
+ if (state !== 'candidate') runtime.entry.draftCandidate = undefined;
1044
+ runtime.entry.draftActive = state === 'active';
1045
+ runtime.entry.draftError = state === 'storage' ? 'storage' : undefined;
1046
+ Reflect.deleteProperty(process.env, 'CLIENT');
1047
+ assert.equal(runtime.entry._renderDraft(), undefined);
1048
+ assert.deepEqual(testIds(runtime.renderDraft()), []);
1049
+ Object.assign(process.env, { CLIENT: 'true' });
1050
+ runtime.entry.$ssr.isRuntimeSsrPreHydration = true;
1051
+ assert.equal(runtime.entry._renderDraft(), undefined);
1052
+ assert.deepEqual(testIds(runtime.renderDraft()), []);
1053
+ runtime.entry.$ssr.isRuntimeSsrPreHydration = false;
1054
+ }
1055
+ });
1056
+ },
1057
+ );
1058
+
1059
+ test(
1060
+ 'SSR start never obtains the coordinator or reads draft storage',
1061
+ { concurrency: false },
1062
+ async () => {
1063
+ await withRuntime(async runtime => {
1064
+ runtime.candidate();
1065
+ Reflect.deleteProperty(process.env, 'CLIENT');
1066
+ await runtime.entry._startDraft();
1067
+ assert.equal(runtime.calls.bean, 0);
1068
+ assert.equal(runtime.calls.open, 0);
1069
+ assert.equal(runtime.saves.length, 0);
1070
+ assert.equal(runtime.entry.draftCandidate, undefined);
1071
+ });
1072
+ },
1073
+ );