@plures/praxis 1.4.0 → 2.0.0

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 (72) hide show
  1. package/dist/browser/{chunk-N63K4KWS.js → chunk-4IRUGWR3.js} +1 -1
  2. package/dist/browser/chunk-6MVRT7CK.js +363 -0
  3. package/dist/browser/chunk-6SJ44Q64.js +473 -0
  4. package/dist/browser/chunk-BQOYZBWA.js +282 -0
  5. package/dist/browser/chunk-IG5BJ2MT.js +91 -0
  6. package/dist/browser/{chunk-MJK3IYTJ.js → chunk-JZDJU2DO.js} +4 -84
  7. package/dist/browser/chunk-ZEW4LJAJ.js +353 -0
  8. package/dist/browser/{engine-YIEGSX7U.js → engine-3B5WJPGT.js} +2 -1
  9. package/dist/browser/expectations/index.d.ts +180 -0
  10. package/dist/browser/expectations/index.js +14 -0
  11. package/dist/browser/factory/index.d.ts +150 -0
  12. package/dist/browser/factory/index.js +15 -0
  13. package/dist/browser/index.d.ts +277 -3
  14. package/dist/browser/index.js +425 -60
  15. package/dist/browser/integrations/svelte.d.ts +4 -2
  16. package/dist/browser/integrations/svelte.js +3 -2
  17. package/dist/browser/project/index.d.ts +177 -0
  18. package/dist/browser/project/index.js +19 -0
  19. package/dist/browser/reactive-engine.svelte-BwWadvAW.d.ts +224 -0
  20. package/dist/browser/rule-result-DcXWe9tn.d.ts +206 -0
  21. package/dist/browser/rules-BaWMqxuG.d.ts +277 -0
  22. package/dist/browser/unified/index.d.ts +239 -0
  23. package/dist/browser/unified/index.js +20 -0
  24. package/dist/node/chunk-6MVRT7CK.js +363 -0
  25. package/dist/node/chunk-AZLNISFI.js +1690 -0
  26. package/dist/node/chunk-IG5BJ2MT.js +91 -0
  27. package/dist/node/{chunk-KMJWAFZV.js → chunk-JZDJU2DO.js} +4 -89
  28. package/dist/node/{chunk-7M3HV4XR.js → chunk-WFRHXZBP.js} +3 -3
  29. package/dist/node/cli/index.cjs +48 -0
  30. package/dist/node/cli/index.js +2 -2
  31. package/dist/node/{engine-FEN5IYZ5.js → engine-VFHCIEM4.js} +2 -1
  32. package/dist/node/index.cjs +2114 -0
  33. package/dist/node/index.d.cts +964 -280
  34. package/dist/node/index.d.ts +964 -280
  35. package/dist/node/index.js +575 -10
  36. package/dist/node/integrations/svelte.d.cts +3 -2
  37. package/dist/node/integrations/svelte.d.ts +3 -2
  38. package/dist/node/integrations/svelte.js +3 -2
  39. package/dist/node/{reactive-engine.svelte-DekxqFu0.d.ts → reactive-engine.svelte-BBZLMzus.d.ts} +3 -79
  40. package/dist/node/{reactive-engine.svelte-Cg0Yc2Hs.d.cts → reactive-engine.svelte-Cbq_V20o.d.cts} +3 -79
  41. package/dist/node/rule-result-B9GMivAn.d.cts +80 -0
  42. package/dist/node/rule-result-Bo3sFMmN.d.ts +80 -0
  43. package/dist/node/{server-SYZPDULV.js → server-FKLVY57V.js} +4 -2
  44. package/dist/node/unified/index.cjs +484 -0
  45. package/dist/node/unified/index.d.cts +240 -0
  46. package/dist/node/unified/index.d.ts +240 -0
  47. package/dist/node/unified/index.js +21 -0
  48. package/dist/node/{validate-TQGVIG7G.js → validate-BY7JNY7H.js} +2 -1
  49. package/package.json +38 -11
  50. package/src/__tests__/chronos-project.test.ts +799 -0
  51. package/src/__tests__/decision-ledger.test.ts +857 -402
  52. package/src/chronos/diff.ts +336 -0
  53. package/src/chronos/hooks.ts +227 -0
  54. package/src/chronos/index.ts +83 -0
  55. package/src/chronos/project-chronicle.ts +198 -0
  56. package/src/chronos/timeline.ts +152 -0
  57. package/src/decision-ledger/analyzer-types.ts +280 -0
  58. package/src/decision-ledger/analyzer.ts +518 -0
  59. package/src/decision-ledger/contract-verification.ts +456 -0
  60. package/src/decision-ledger/derivation.ts +158 -0
  61. package/src/decision-ledger/index.ts +59 -0
  62. package/src/decision-ledger/report.ts +378 -0
  63. package/src/decision-ledger/suggestions.ts +287 -0
  64. package/src/index.browser.ts +103 -0
  65. package/src/index.ts +98 -0
  66. package/src/unified/__tests__/unified.test.ts +396 -0
  67. package/src/unified/core.ts +517 -0
  68. package/src/unified/index.ts +32 -0
  69. package/src/unified/rules.ts +66 -0
  70. package/src/unified/types.ts +148 -0
  71. package/dist/browser/reactive-engine.svelte-DjynI82A.d.ts +0 -688
  72. package/dist/node/chunk-FWOXU4MM.js +0 -487
@@ -220,3 +220,106 @@ export {
220
220
  // Unified Integration Helpers
221
221
  export type { UnifiedAppConfig, UnifiedApp } from './integrations/unified.js';
222
222
  export { createUnifiedApp, attachAllIntegrations } from './integrations/unified.js';
223
+
224
+ // ── Rule Result (typed rule returns — no empty arrays) ──────────────────────
225
+ export { RuleResult, fact } from './core/rule-result.js';
226
+ export type { TypedRuleFn } from './core/rule-result.js';
227
+
228
+ // ── UI Rules (predefined, lightweight, separate from business logic) ────────
229
+ export {
230
+ uiModule,
231
+ createUIModule,
232
+ loadingGateRule,
233
+ errorDisplayRule,
234
+ offlineIndicatorRule,
235
+ dirtyGuardRule,
236
+ initGateRule,
237
+ viewportRule,
238
+ noInteractionWhileLoadingConstraint,
239
+ mustBeInitializedConstraint,
240
+ uiStateChanged,
241
+ navigationRequest,
242
+ resizeEvent,
243
+ } from './core/ui-rules.js';
244
+ export type { UIContext } from './core/ui-rules.js';
245
+
246
+ // ── Completeness Analysis ───────────────────────────────────────────────────
247
+ export { auditCompleteness, formatReport } from './core/completeness.js';
248
+ export type { LogicBranch, StateField, StateTransition, CompletenessReport, CompletenessConfig } from './core/completeness.js';
249
+
250
+ // ── Expectations DSL (behavioral declarations) ─────────────────────────────
251
+ export {
252
+ Expectation,
253
+ ExpectationSet,
254
+ expectBehavior,
255
+ verify,
256
+ formatVerificationReport,
257
+ } from './expectations/index.js';
258
+ export type {
259
+ ExpectationCondition,
260
+ ConditionStatus,
261
+ ConditionResult,
262
+ ExpectationResult,
263
+ VerificationReport,
264
+ ExpectationSetOptions,
265
+ VerifiableRegistry,
266
+ VerifiableDescriptor,
267
+ } from './expectations/index.js';
268
+
269
+ // ── Rules Factory (predefined modules) ─────────────────────────────────────
270
+ export {
271
+ inputRules,
272
+ toastRules,
273
+ formRules,
274
+ navigationRules,
275
+ dataRules,
276
+ } from './factory/index.js';
277
+ export type {
278
+ InputRulesConfig,
279
+ ToastRulesConfig,
280
+ FormRulesConfig,
281
+ NavigationRulesConfig,
282
+ DataRulesConfig,
283
+ SanitizationType,
284
+ } from './factory/index.js';
285
+
286
+ // ── Project Logic (developer workflow) ──────────────────────────────────────
287
+ export {
288
+ defineGate,
289
+ semverContract,
290
+ commitFromState,
291
+ branchRules,
292
+ lintGate,
293
+ formatGate,
294
+ expectationGate,
295
+ } from './project/index.js';
296
+ export type {
297
+ GateConfig,
298
+ GateState,
299
+ GateStatus,
300
+ SemverContractConfig,
301
+ SemverReport,
302
+ PraxisDiff,
303
+ BranchRulesConfig,
304
+ PredefinedGateConfig,
305
+ } from './project/index.js';
306
+
307
+ // ── Unified Reactive Layer (v2.0) ───────────────────────────────────────────
308
+ export { createApp } from './unified/core.js';
309
+ export type { PraxisApp } from './unified/core.js';
310
+ export {
311
+ definePath,
312
+ defineRule as defineUnifiedRule,
313
+ defineConstraint as defineUnifiedConstraint,
314
+ defineModule as defineUnifiedModule,
315
+ } from './unified/index.js';
316
+ export type {
317
+ PathSchema,
318
+ QueryOptions,
319
+ ReactiveRef,
320
+ MutationResult,
321
+ UnifiedRule,
322
+ UnifiedConstraint,
323
+ LivenessConfig,
324
+ PraxisAppConfig,
325
+ } from './unified/types.js';
package/src/index.ts CHANGED
@@ -155,6 +155,51 @@ export type {
155
155
  LedgerEntryStatus,
156
156
  } from './decision-ledger/index.js';
157
157
 
158
+ // Decision Ledger — Analyzer Engine
159
+ export {
160
+ analyzeDependencyGraph,
161
+ findDeadRules,
162
+ findUnreachableStates,
163
+ findShadowedRules,
164
+ findContradictions,
165
+ findGaps,
166
+ traceDerivation,
167
+ traceImpact,
168
+ verifyContractExamples,
169
+ verifyInvariants,
170
+ findContractGaps,
171
+ crossReferenceContracts,
172
+ suggest,
173
+ suggestAll,
174
+ generateLedger,
175
+ formatLedger,
176
+ formatBuildOutput,
177
+ diffLedgers,
178
+ } from './decision-ledger/index.js';
179
+ export type {
180
+ FactNode,
181
+ DependencyEdge,
182
+ DependencyGraph,
183
+ DerivationStep,
184
+ DerivationChain,
185
+ DeadRule,
186
+ UnreachableState,
187
+ ShadowedRule,
188
+ Contradiction,
189
+ Gap,
190
+ ImpactReport,
191
+ ExampleVerification,
192
+ ContractVerificationResult,
193
+ InvariantCheck,
194
+ ContractCoverageGap,
195
+ CrossReference,
196
+ FindingType,
197
+ Suggestion,
198
+ AnalysisReport,
199
+ LedgerDiffEntry,
200
+ LedgerDiff,
201
+ } from './decision-ledger/index.js';
202
+
158
203
  // Terminal Node Runtime
159
204
  export type {
160
205
  TerminalExecutionResult,
@@ -410,3 +455,56 @@ export type {
410
455
  PredefinedGateConfig,
411
456
  } from './project/index.js';
412
457
 
458
+ // ── Chronos Project-Level Chronicle ─────────────────────────────────────────
459
+ export {
460
+ ProjectChronicle,
461
+ createProjectChronicle,
462
+ Timeline,
463
+ createTimeline,
464
+ enableProjectChronicle,
465
+ recordAudit,
466
+ diffRegistries,
467
+ diffContracts,
468
+ diffExpectations,
469
+ formatDelta,
470
+ formatCommitMessage as formatBehavioralCommit,
471
+ formatReleaseNotes,
472
+ } from './chronos/index.js';
473
+ export type {
474
+ ProjectEvent,
475
+ ProjectEventKind,
476
+ ProjectChronicleOptions,
477
+ TimelineFilter,
478
+ BehavioralDelta,
479
+ ChronicleHandle,
480
+ EnableChronicleOptions,
481
+ RegistrySnapshot,
482
+ RegistryDiff,
483
+ ContractCoverage,
484
+ ContractDiff,
485
+ ExpectationSnapshot,
486
+ ExpectationDiff,
487
+ FullBehavioralDiff,
488
+ } from './chronos/index.js';
489
+
490
+
491
+ // ── Unified Reactive Layer (v2.0) ───────────────────────────────────────────
492
+ // The zero-boilerplate API: createApp → query() + mutate()
493
+ export { createApp } from './unified/core.js';
494
+ export type { PraxisApp } from './unified/core.js';
495
+ export {
496
+ definePath,
497
+ defineRule as defineUnifiedRule,
498
+ defineConstraint as defineUnifiedConstraint,
499
+ defineModule as defineUnifiedModule,
500
+ } from './unified/index.js';
501
+ export type {
502
+ PathSchema,
503
+ QueryOptions,
504
+ ReactiveRef,
505
+ MutationResult,
506
+ UnifiedRule,
507
+ UnifiedConstraint,
508
+ LivenessConfig,
509
+ PraxisAppConfig,
510
+ } from './unified/types.js';
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Praxis Unified Reactive Layer — Tests
3
+ *
4
+ * Tests that the unified API works end-to-end:
5
+ * - Schema → query() → subscribe → reactive updates
6
+ * - mutate() → constraint check → rule evaluation → fact emission
7
+ * - Liveness detection
8
+ * - Batch mutations
9
+ * - Timeline logging
10
+ */
11
+
12
+ import { describe, it, expect, vi } from 'vitest';
13
+ import {
14
+ createApp,
15
+ definePath,
16
+ defineRule,
17
+ defineConstraint,
18
+ RuleResult,
19
+ fact,
20
+ } from '../index.js';
21
+
22
+ // ── Test Schema ─────────────────────────────────────────────────────────────
23
+
24
+ interface SprintInfo {
25
+ name: string;
26
+ currentDay: number;
27
+ totalDays: number;
28
+ completedHours: number;
29
+ totalHours: number;
30
+ }
31
+
32
+ const Sprint = definePath<SprintInfo | null>('sprint/current', null);
33
+ const Loading = definePath<boolean>('sprint/loading', false);
34
+ const Items = definePath<Array<{ id: number; state: string; completedWork: number }>>('sprint/items', []);
35
+
36
+ // ── Test Rules ──────────────────────────────────────────────────────────────
37
+
38
+ const sprintBehindRule = defineRule({
39
+ id: 'sprint.behind',
40
+ watch: ['sprint/current'],
41
+ evaluate: (values) => {
42
+ const sprint = values['sprint/current'] as SprintInfo | null;
43
+ if (!sprint) return RuleResult.skip('No sprint');
44
+ const pace = sprint.currentDay / sprint.totalDays;
45
+ const work = sprint.completedHours / sprint.totalHours;
46
+ if (work >= pace) return RuleResult.retract(['sprint.behind']);
47
+ return RuleResult.emit([fact('sprint.behind', { pace, work })]);
48
+ },
49
+ });
50
+
51
+ const loadingRule = defineRule({
52
+ id: 'sprint.loading-check',
53
+ watch: ['sprint/loading', 'sprint/current'],
54
+ evaluate: (values) => {
55
+ const loading = values['sprint/loading'] as boolean;
56
+ const sprint = values['sprint/current'];
57
+ if (loading && !sprint) {
58
+ return RuleResult.emit([fact('sprint.still-loading', { since: Date.now() })]);
59
+ }
60
+ return RuleResult.retract(['sprint.still-loading']);
61
+ },
62
+ });
63
+
64
+ // ── Test Constraints ────────────────────────────────────────────────────────
65
+
66
+ const noCloseWithoutHours = defineConstraint({
67
+ id: 'no-close-without-hours',
68
+ description: 'Cannot have closed items with 0 completed hours',
69
+ watch: ['sprint/items'],
70
+ validate: (values) => {
71
+ const items = (values['sprint/items'] ?? []) as Array<{ id: number; state: string; completedWork: number }>;
72
+ const bad = items.find(i => i.state === 'Closed' && !i.completedWork);
73
+ if (bad) return `Item #${bad.id} cannot be closed with 0 completed hours`;
74
+ return true;
75
+ },
76
+ });
77
+
78
+ // ── Tests ───────────────────────────────────────────────────────────────────
79
+
80
+ describe('Unified Reactive Layer', () => {
81
+ describe('createApp', () => {
82
+ it('creates an app with schema', () => {
83
+ const app = createApp({
84
+ name: 'test',
85
+ schema: [Sprint, Loading, Items],
86
+ });
87
+ expect(app).toBeDefined();
88
+ expect(app.query).toBeTypeOf('function');
89
+ expect(app.mutate).toBeTypeOf('function');
90
+ app.destroy();
91
+ });
92
+ });
93
+
94
+ describe('query()', () => {
95
+ it('returns initial value from schema', () => {
96
+ const app = createApp({ name: 'test', schema: [Sprint, Loading] });
97
+ const sprint = app.query<SprintInfo | null>('sprint/current');
98
+ expect(sprint.current).toBeNull();
99
+ app.destroy();
100
+ });
101
+
102
+ it('is Svelte store compatible (subscribe with immediate callback)', () => {
103
+ const app = createApp({ name: 'test', schema: [Loading] });
104
+ const loading = app.query<boolean>('sprint/loading');
105
+ const values: boolean[] = [];
106
+ const unsub = loading.subscribe(v => values.push(v));
107
+ // Immediate callback on subscribe
108
+ expect(values).toEqual([false]);
109
+ unsub();
110
+ app.destroy();
111
+ });
112
+
113
+ it('updates reactively on mutate', () => {
114
+ const app = createApp({ name: 'test', schema: [Sprint, Loading] });
115
+ const sprint = app.query<SprintInfo | null>('sprint/current');
116
+ const values: Array<SprintInfo | null> = [];
117
+ const unsub = sprint.subscribe(v => values.push(v));
118
+
119
+ // Initial
120
+ expect(values.length).toBe(1);
121
+ expect(values[0]).toBeNull();
122
+
123
+ // Mutate
124
+ const data: SprintInfo = { name: 'Sprint 1', currentDay: 3, totalDays: 10, completedHours: 5, totalHours: 20 };
125
+ app.mutate('sprint/current', data);
126
+
127
+ expect(values.length).toBe(2);
128
+ expect(values[1]).toEqual(data);
129
+ expect(sprint.current).toEqual(data);
130
+
131
+ unsub();
132
+ app.destroy();
133
+ });
134
+
135
+ it('supports query options (where, sort, limit)', () => {
136
+ const app = createApp({ name: 'test', schema: [Items] });
137
+ const activeItems = app.query<Array<{ id: number; state: string; completedWork: number }>>('sprint/items', {
138
+ where: (item) => item.state === 'Active',
139
+ sort: (a, b) => a.id - b.id,
140
+ limit: 2,
141
+ });
142
+
143
+ app.mutate('sprint/items', [
144
+ { id: 3, state: 'Active', completedWork: 1 },
145
+ { id: 1, state: 'Active', completedWork: 0 },
146
+ { id: 2, state: 'Closed', completedWork: 5 },
147
+ { id: 4, state: 'Active', completedWork: 2 },
148
+ ]);
149
+
150
+ expect(activeItems.current).toEqual([
151
+ { id: 1, state: 'Active', completedWork: 0 },
152
+ { id: 3, state: 'Active', completedWork: 1 },
153
+ ]);
154
+ app.destroy();
155
+ });
156
+ });
157
+
158
+ describe('mutate()', () => {
159
+ it('returns accepted: true when no constraints violated', () => {
160
+ const app = createApp({ name: 'test', schema: [Sprint] });
161
+ const result = app.mutate('sprint/current', { name: 'Sprint 1', currentDay: 1, totalDays: 10, completedHours: 0, totalHours: 20 });
162
+ expect(result.accepted).toBe(true);
163
+ expect(result.violations).toEqual([]);
164
+ app.destroy();
165
+ });
166
+
167
+ it('rejects mutation when constraint violated', () => {
168
+ const app = createApp({
169
+ name: 'test',
170
+ schema: [Items],
171
+ constraints: [noCloseWithoutHours],
172
+ });
173
+
174
+ const result = app.mutate('sprint/items', [
175
+ { id: 1, state: 'Closed', completedWork: 0 },
176
+ ]);
177
+
178
+ expect(result.accepted).toBe(false);
179
+ expect(result.violations.length).toBe(1);
180
+ expect(result.violations[0].message).toContain('Item #1');
181
+
182
+ // Value should NOT have been written
183
+ const items = app.query('sprint/items');
184
+ expect(items.current).toEqual([]);
185
+
186
+ app.destroy();
187
+ });
188
+
189
+ it('triggers rule evaluation', () => {
190
+ const app = createApp({
191
+ name: 'test',
192
+ schema: [Sprint],
193
+ rules: [sprintBehindRule],
194
+ });
195
+
196
+ // Behind: day 5/10 but only 2/20 hours done
197
+ app.mutate('sprint/current', {
198
+ name: 'Sprint 1',
199
+ currentDay: 5,
200
+ totalDays: 10,
201
+ completedHours: 2,
202
+ totalHours: 20,
203
+ });
204
+
205
+ const facts = app.facts();
206
+ expect(facts.some(f => f.tag === 'sprint.behind')).toBe(true);
207
+
208
+ // On pace: day 5/10, 12/20 hours done
209
+ app.mutate('sprint/current', {
210
+ name: 'Sprint 1',
211
+ currentDay: 5,
212
+ totalDays: 10,
213
+ completedHours: 12,
214
+ totalHours: 20,
215
+ });
216
+
217
+ const factsAfter = app.facts();
218
+ expect(factsAfter.some(f => f.tag === 'sprint.behind')).toBe(false);
219
+
220
+ app.destroy();
221
+ });
222
+ });
223
+
224
+ describe('batch()', () => {
225
+ it('applies multiple mutations atomically', () => {
226
+ const app = createApp({
227
+ name: 'test',
228
+ schema: [Sprint, Loading],
229
+ rules: [loadingRule],
230
+ });
231
+
232
+ const values: Array<SprintInfo | null> = [];
233
+ const sprintRef = app.query<SprintInfo | null>('sprint/current');
234
+ sprintRef.subscribe(v => values.push(v));
235
+
236
+ const result = app.batch((m) => {
237
+ m('sprint/loading', true);
238
+ m('sprint/current', { name: 'Sprint 1', currentDay: 1, totalDays: 10, completedHours: 0, totalHours: 20 });
239
+ });
240
+
241
+ expect(result.accepted).toBe(true);
242
+ // Sprint was set
243
+ expect(sprintRef.current).toEqual({ name: 'Sprint 1', currentDay: 1, totalDays: 10, completedHours: 0, totalHours: 20 });
244
+
245
+ app.destroy();
246
+ });
247
+
248
+ it('rejects entire batch on constraint violation', () => {
249
+ const app = createApp({
250
+ name: 'test',
251
+ schema: [Sprint, Items],
252
+ constraints: [noCloseWithoutHours],
253
+ });
254
+
255
+ const result = app.batch((m) => {
256
+ m('sprint/current', { name: 'Sprint 1', currentDay: 1, totalDays: 10, completedHours: 0, totalHours: 20 });
257
+ m('sprint/items', [{ id: 1, state: 'Closed', completedWork: 0 }]);
258
+ });
259
+
260
+ expect(result.accepted).toBe(false);
261
+ // Neither mutation should have been applied
262
+ expect(app.query('sprint/current').current).toBeNull();
263
+
264
+ app.destroy();
265
+ });
266
+ });
267
+
268
+ describe('timeline', () => {
269
+ it('records mutations', () => {
270
+ const app = createApp({ name: 'test', schema: [Loading] });
271
+ app.mutate('sprint/loading', true);
272
+ app.mutate('sprint/loading', false);
273
+
274
+ const tl = app.timeline();
275
+ const mutations = tl.filter(e => e.kind === 'mutation');
276
+ expect(mutations.length).toBe(2);
277
+ expect(mutations[0].path).toBe('sprint/loading');
278
+ expect(mutations[0].data.before).toBe(false);
279
+ expect(mutations[0].data.after).toBe(true);
280
+
281
+ app.destroy();
282
+ });
283
+
284
+ it('records rule evaluations', () => {
285
+ const app = createApp({
286
+ name: 'test',
287
+ schema: [Sprint],
288
+ rules: [sprintBehindRule],
289
+ });
290
+
291
+ app.mutate('sprint/current', { name: 'Sprint 1', currentDay: 5, totalDays: 10, completedHours: 2, totalHours: 20 });
292
+
293
+ const tl = app.timeline();
294
+ const ruleEvals = tl.filter(e => e.kind === 'rule-eval');
295
+ expect(ruleEvals.some(e => e.data.ruleId === 'sprint.behind')).toBe(true);
296
+
297
+ app.destroy();
298
+ });
299
+ });
300
+
301
+ describe('liveness', () => {
302
+ it('detects stale paths', async () => {
303
+ const staleCb = vi.fn();
304
+
305
+ const app = createApp({
306
+ name: 'test',
307
+ schema: [Sprint, Loading],
308
+ liveness: {
309
+ expect: ['sprint/current'],
310
+ timeoutMs: 50,
311
+ onStale: staleCb,
312
+ },
313
+ });
314
+
315
+ // Don't mutate sprint/current — it should go stale
316
+ await new Promise(r => setTimeout(r, 100));
317
+
318
+ expect(staleCb).toHaveBeenCalledWith('sprint/current', expect.any(Number));
319
+
320
+ const status = app.liveness();
321
+ expect(status['sprint/current'].stale).toBe(true);
322
+
323
+ app.destroy();
324
+ });
325
+
326
+ it('does NOT flag paths that were updated', async () => {
327
+ const staleCb = vi.fn();
328
+
329
+ const app = createApp({
330
+ name: 'test',
331
+ schema: [Sprint],
332
+ liveness: {
333
+ expect: ['sprint/current'],
334
+ timeoutMs: 100,
335
+ onStale: staleCb,
336
+ },
337
+ });
338
+
339
+ // Update immediately
340
+ app.mutate('sprint/current', { name: 'Sprint 1', currentDay: 1, totalDays: 10, completedHours: 0, totalHours: 20 });
341
+
342
+ await new Promise(r => setTimeout(r, 150));
343
+
344
+ // onStale should NOT have been called
345
+ expect(staleCb).not.toHaveBeenCalled();
346
+
347
+ app.destroy();
348
+ });
349
+ });
350
+
351
+ describe('multiple subscribers', () => {
352
+ it('notifies all subscribers independently', () => {
353
+ const app = createApp({ name: 'test', schema: [Loading] });
354
+
355
+ const values1: boolean[] = [];
356
+ const values2: boolean[] = [];
357
+
358
+ const ref1 = app.query<boolean>('sprint/loading');
359
+ const ref2 = app.query<boolean>('sprint/loading');
360
+
361
+ const unsub1 = ref1.subscribe(v => values1.push(v));
362
+ const unsub2 = ref2.subscribe(v => values2.push(v));
363
+
364
+ app.mutate('sprint/loading', true);
365
+
366
+ expect(values1).toEqual([false, true]);
367
+ expect(values2).toEqual([false, true]);
368
+
369
+ unsub1();
370
+ app.mutate('sprint/loading', false);
371
+
372
+ // Only ref2 should get the update
373
+ expect(values1).toEqual([false, true]);
374
+ expect(values2).toEqual([false, true, false]);
375
+
376
+ unsub2();
377
+ app.destroy();
378
+ });
379
+ });
380
+
381
+ describe('destroy', () => {
382
+ it('cleans up all state', () => {
383
+ const app = createApp({
384
+ name: 'test',
385
+ schema: [Sprint, Loading, Items],
386
+ rules: [sprintBehindRule],
387
+ });
388
+
389
+ app.mutate('sprint/current', { name: 'Sprint 1', currentDay: 1, totalDays: 10, completedHours: 0, totalHours: 20 });
390
+ app.destroy();
391
+
392
+ expect(app.facts()).toEqual([]);
393
+ expect(app.timeline()).toEqual([]);
394
+ });
395
+ });
396
+ });