@owlmeans/client-planning 0.1.18-rc.9 → 0.1.18

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 (146) hide show
  1. package/README.md +94 -11
  2. package/agent-meta/manifest.json +62 -4
  3. package/agent-meta/skills/client-planning/SKILL.md +60 -78
  4. package/agent-meta/skills/client-planning/parts/aux-mirrors.md +61 -0
  5. package/agent-meta/skills/client-planning/parts/mirror.md +67 -0
  6. package/agent-meta/skills/client-planning/parts/validation.md +60 -0
  7. package/build/client.d.ts +1 -1
  8. package/build/client.d.ts.map +1 -1
  9. package/build/client.js +66 -14
  10. package/build/client.js.map +1 -1
  11. package/build/commits.d.ts +2 -13
  12. package/build/commits.d.ts.map +1 -1
  13. package/build/commits.js +190 -132
  14. package/build/commits.js.map +1 -1
  15. package/build/consts.d.ts +4 -0
  16. package/build/consts.d.ts.map +1 -1
  17. package/build/consts.js +10 -4
  18. package/build/consts.js.map +1 -1
  19. package/build/definitions.d.ts +13 -0
  20. package/build/definitions.d.ts.map +1 -0
  21. package/build/definitions.js +70 -0
  22. package/build/definitions.js.map +1 -0
  23. package/build/events/types.d.ts +34 -0
  24. package/build/events/types.d.ts.map +1 -0
  25. package/build/events/types.js +2 -0
  26. package/build/events/types.js.map +1 -0
  27. package/build/events.d.ts +10 -21
  28. package/build/events.d.ts.map +1 -1
  29. package/build/events.js +104 -102
  30. package/build/events.js.map +1 -1
  31. package/build/facade.d.ts.map +1 -1
  32. package/build/facade.js +86 -49
  33. package/build/facade.js.map +1 -1
  34. package/build/feed.d.ts +4 -4
  35. package/build/feed.d.ts.map +1 -1
  36. package/build/feed.js +50 -27
  37. package/build/feed.js.map +1 -1
  38. package/build/helper/types.d.ts +15 -0
  39. package/build/helper/types.d.ts.map +1 -0
  40. package/build/helper/types.js +2 -0
  41. package/build/helper/types.js.map +1 -0
  42. package/build/helper.d.ts +7 -7
  43. package/build/helper.d.ts.map +1 -1
  44. package/build/helper.js +16 -8
  45. package/build/helper.js.map +1 -1
  46. package/build/index.d.ts +11 -0
  47. package/build/index.d.ts.map +1 -1
  48. package/build/index.js +6 -0
  49. package/build/index.js.map +1 -1
  50. package/build/lifecycle/types.d.ts +27 -0
  51. package/build/lifecycle/types.d.ts.map +1 -0
  52. package/build/lifecycle/types.js +2 -0
  53. package/build/lifecycle/types.js.map +1 -0
  54. package/build/lifecycle.d.ts +3 -0
  55. package/build/lifecycle.d.ts.map +1 -0
  56. package/build/lifecycle.js +157 -0
  57. package/build/lifecycle.js.map +1 -0
  58. package/build/resource-feed.d.ts +5 -0
  59. package/build/resource-feed.d.ts.map +1 -0
  60. package/build/resource-feed.js +97 -0
  61. package/build/resource-feed.js.map +1 -0
  62. package/build/resources.d.ts +6 -0
  63. package/build/resources.d.ts.map +1 -0
  64. package/build/resources.js +93 -0
  65. package/build/resources.js.map +1 -0
  66. package/build/stores.d.ts +4 -11
  67. package/build/stores.d.ts.map +1 -1
  68. package/build/stores.js +11 -35
  69. package/build/stores.js.map +1 -1
  70. package/build/sync/types.d.ts +18 -0
  71. package/build/sync/types.d.ts.map +1 -0
  72. package/build/sync/types.js +2 -0
  73. package/build/sync/types.js.map +1 -0
  74. package/build/sync.d.ts +4 -0
  75. package/build/sync.d.ts.map +1 -0
  76. package/build/sync.js +26 -0
  77. package/build/sync.js.map +1 -0
  78. package/build/types.d.ts +48 -3
  79. package/build/types.d.ts.map +1 -1
  80. package/build/types.local.d.ts +13 -0
  81. package/build/types.local.d.ts.map +1 -0
  82. package/build/types.local.js +2 -0
  83. package/build/types.local.js.map +1 -0
  84. package/build/utils/record/types.d.ts +28 -0
  85. package/build/utils/record/types.d.ts.map +1 -0
  86. package/build/utils/record/types.js +2 -0
  87. package/build/utils/record/types.js.map +1 -0
  88. package/build/utils/record.d.ts +3 -24
  89. package/build/utils/record.d.ts.map +1 -1
  90. package/build/utils/record.js +64 -70
  91. package/build/utils/record.js.map +1 -1
  92. package/build/validation/consts.d.ts +23 -0
  93. package/build/validation/consts.d.ts.map +1 -0
  94. package/build/validation/consts.js +289 -0
  95. package/build/validation/consts.js.map +1 -0
  96. package/build/validation/types.d.ts +17 -0
  97. package/build/validation/types.d.ts.map +1 -0
  98. package/build/validation/types.js +2 -0
  99. package/build/validation/types.js.map +1 -0
  100. package/build/validation.d.ts +10 -0
  101. package/build/validation.d.ts.map +1 -0
  102. package/build/validation.js +171 -0
  103. package/build/validation.js.map +1 -0
  104. package/package.json +20 -18
  105. package/src/client.ts +58 -15
  106. package/src/commits.ts +152 -184
  107. package/src/consts.ts +12 -4
  108. package/src/definitions.ts +93 -0
  109. package/src/events/types.ts +35 -0
  110. package/src/events.ts +118 -108
  111. package/src/facade.ts +87 -55
  112. package/src/feed.ts +47 -28
  113. package/src/helper/types.ts +15 -0
  114. package/src/helper.ts +27 -10
  115. package/src/index.ts +12 -0
  116. package/src/lifecycle/types.ts +28 -0
  117. package/src/lifecycle.ts +123 -0
  118. package/src/resource-feed.ts +79 -0
  119. package/src/resources.ts +84 -0
  120. package/src/stores.ts +11 -40
  121. package/src/sync/types.ts +18 -0
  122. package/src/sync.ts +40 -0
  123. package/src/types.local.ts +14 -0
  124. package/src/types.ts +53 -5
  125. package/src/utils/record/types.ts +27 -0
  126. package/src/utils/record.ts +68 -71
  127. package/src/validation/consts.ts +289 -0
  128. package/src/validation/types.ts +18 -0
  129. package/src/validation.ts +140 -0
  130. package/tests/app.spec.ts +72 -0
  131. package/tests/context.ts +46 -15
  132. package/tests/csp.spec.ts +119 -0
  133. package/tests/definitions.spec.ts +74 -0
  134. package/tests/draft7.spec.ts +13 -0
  135. package/tests/events.spec.ts +13 -9
  136. package/tests/facade.spec.ts +2 -1
  137. package/tests/lifecycle/context.ts +29 -0
  138. package/tests/lifecycle-close.spec.ts +103 -0
  139. package/tests/lifecycle-commits.spec.ts +92 -0
  140. package/tests/lifecycle-definitions.spec.ts +28 -0
  141. package/tests/lifecycle-feeds.spec.ts +92 -0
  142. package/tests/lifecycle-sockets.spec.ts +76 -0
  143. package/tests/replies.spec.ts +81 -0
  144. package/tests/resources.spec.ts +116 -0
  145. package/tests/stores.spec.ts +13 -11
  146. package/tests/validation.spec.ts +127 -0
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { CommitTimeout, TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
+ import { makePlanningResourceFeed } from '../src/index.js'
4
+ import { makeSuite, protocols, STORY, tick } from './context.js'
5
+ import { makeBarrier } from './lifecycle/context.js'
6
+
7
+ describe('@owlmeans/client-planning — commit cancellation', () => {
8
+ test('rejects delayed status and held replies, and never retries a stale failed hold', async () => {
9
+ for (const mode of ['status', 'hold', 'failed-hold'] as const) {
10
+ let identity = 'a'
11
+ let active = false
12
+ const started = makeBarrier()
13
+ const reply = makeBarrier()
14
+ const suite = await makeSuite({ sync: false, schemas: false, scopeKey: () => identity,
15
+ before: async request => { if (active && request.alias === protocols.commit.get.alias && Number((request.query as Record<string, unknown>)?.wait) > 0) { void started.hold() } },
16
+ after: async call => {
17
+ if (!active || call.alias !== protocols.commit.get.alias || (mode === 'status') !== (Number(call.query.wait) === 0)) return
18
+ await reply.hold()
19
+ if (mode === 'failed-hold') throw new Error('Carrier dropped after close')
20
+ },
21
+ })
22
+ const project = await suite.project()
23
+ const receipt = await suite.planning.execute({ action: TransitionAction.Create, card: { kind: WorkcardKind.Card, type: STORY, parent: project.id!, title: 'Delayed old card' } })
24
+ active = true
25
+ const waiting = receipt.committed({ timeout: 5000 })
26
+ void waiting.catch(() => {})
27
+ if (mode !== 'status') { await started.entered; await suite.store.flush() }
28
+ await reply.entered
29
+ await suite.client.planning().close()
30
+ await expect(waiting).rejects.toThrow('scope-changed')
31
+ identity = 'b'
32
+ const count = suite.calls.length
33
+ reply.release(); started.release()
34
+ await tick(10)
35
+ expect(suite.calls.length).toBe(count)
36
+ expect((await suite.client.planningStores!().cards.list({}, { size: 0 })).items).toEqual([])
37
+ expect((await suite.client.planningStores!().commits.list({}, { size: 0 })).items).toEqual([])
38
+ await suite.client.planning().close()
39
+ }
40
+ })
41
+
42
+ test('a deadline also bounds an unresolved socket opener and an initial status RPC', async () => {
43
+ for (const socket of [true, false]) {
44
+ const pause = makeBarrier()
45
+ const suite = await makeSuite({ schemas: false, socket,
46
+ opening: async () => await pause.hold(),
47
+ after: async call => { if (!socket && call.alias === protocols.commit.get.alias) await pause.hold() },
48
+ })
49
+ const project = await suite.project()
50
+ const waiting = suite.planning.commits.wait((await suite.local.transitions.list({ card: project.id! })).items[0]!.id!, { timeout: 40 })
51
+ void waiting.catch(() => {})
52
+ await pause.entered
53
+ await expect(waiting).rejects.toBeInstanceOf(CommitTimeout)
54
+ await suite.client.planning().close()
55
+ pause.release()
56
+ await tick()
57
+ expect(suite.client.planning().commits.connected()).toBe(false)
58
+ }
59
+ })
60
+
61
+ test('a new organization works after close while a late old reply cannot enter its mirror', async () => {
62
+ let identity = 'organization-a'
63
+ const pause = makeBarrier()
64
+ let delay = false
65
+ const suite = await makeSuite({ resources: true, schemas: false, entityId: () => identity, scopeKey: () => identity,
66
+ after: async call => { if (delay && call.alias === protocols.commit.get.alias) await pause.hold() },
67
+ })
68
+ const project = await suite.project()
69
+ delay = true
70
+ let invalidations = 0
71
+ suite.client.planning().lifecycle.onInvalidate(() => { invalidations++ })
72
+ const old = suite.planning.commits.wait((await suite.local.transitions.list({ card: project.id! })).items[0]!.id!, { timeout: 5000 })
73
+ void old.catch(() => {})
74
+ await pause.entered
75
+ await suite.client.planning().close()
76
+ await expect(old).rejects.toThrow('scope-changed')
77
+ expect(invalidations).toBe(1)
78
+ identity = 'organization-b'
79
+ await suite.client.planning().loadSchemas()
80
+ const feed = makePlanningResourceFeed(suite.client, { teams: { size: 0 }, refresh: 0 })
81
+ await feed.ready
82
+ expect(feed.seeded).toBe(true)
83
+ expect(invalidations).toBe(1)
84
+ const fresh = await suite.planning.teams.create({ name: 'Organization B', fields: {} })
85
+ pause.release()
86
+ await tick(10)
87
+ expect((await suite.client.planningStores!().teams.list({}, { size: 0 })).items.map(team => team.id)).toEqual([fresh.id])
88
+ expect((await suite.client.planningStores!().cards.list({}, { size: 0 })).items).toEqual([])
89
+ expect((await suite.local.teams.list()).items).toEqual([])
90
+ await suite.client.planning().close()
91
+ })
92
+ })
@@ -0,0 +1,28 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { WorkcardKind } from '@owlmeans/planning'
3
+ import { definitionProtocols, makeSuite, STORY_FLOW, tick } from './context.js'
4
+ import { makeBarrier } from './lifecycle/context.js'
5
+
6
+ test('a stale schema write rejects without invalidating the fresh organization registry', async () => {
7
+ let identity = 'organization-a'
8
+ const pause = makeBarrier()
9
+ const suite = await makeSuite({ definitions: true, schemas: false, entityId: () => identity, scopeKey: () => identity,
10
+ after: async call => { if (call.alias === definitionProtocols.schema.define!.alias) await pause.hold() },
11
+ })
12
+ const definitions = suite.planning.definitions!
13
+ const writing = definitions.define({ types: [{ type: 'test:old-type', kind: WorkcardKind.Card, version: 1,
14
+ fields: { type: 'object' }, flows: [STORY_FLOW], specifications: [] }] })
15
+ void writing.catch(() => {})
16
+ await pause.entered
17
+ await suite.client.planning().close()
18
+ await expect(writing).rejects.toThrow('scope-changed')
19
+ identity = 'organization-b'
20
+ const fresh = await definitions.registry()
21
+ const reads = suite.calls.filter(call => call.alias === definitionProtocols.schema.list.alias).length
22
+ pause.release(); await tick(10)
23
+ expect(await definitions.registry()).toBe(fresh)
24
+ expect(suite.calls.filter(call => call.alias === definitionProtocols.schema.list.alias)).toHaveLength(reads)
25
+ expect(fresh.bundle().types.some(type => type.type === 'test:old-type')).toBe(false)
26
+ expect((await suite.local.definitions!.bundle()).types.some(type => type.type === 'test:old-type')).toBe(true)
27
+ await suite.client.planning().close()
28
+ })
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { CommitState, PLANNING_COMMIT_EVENT, TransitionAction } from '@owlmeans/planning'
3
+ import type { Connection } from '@owlmeans/socket'
4
+ import { makePlanningFeed, makePlanningResourceFeed } from '../src/index.js'
5
+ import { makeSuite, protocols, tick } from './context.js'
6
+ import { makeBarrier } from './lifecycle/context.js'
7
+
8
+ describe('@owlmeans/client-planning — feed boundaries', () => {
9
+ test('stops both feed kinds without waiting for an unresolved seed, preserving fresh organization rows', async () => {
10
+ for (const kind of ['cards', 'teams'] as const) {
11
+ let identity = 'organization-a'
12
+ const pause = makeBarrier()
13
+ const suite = await makeSuite({ schemas: false, resources: true, entityId: () => identity, scopeKey: () => identity,
14
+ after: async call => { if (kind === 'cards' ? call.alias === protocols.card.list.alias : call.alias.endsWith(':teams:list')) await pause.hold() },
15
+ })
16
+ await suite.project()
17
+ await suite.local.teams.create({ name: 'Old team', fields: {} })
18
+ let notifications = 0
19
+ const feed = kind === 'cards'
20
+ ? makePlanningFeed(suite.client, { onChange: () => { notifications++ } })
21
+ : makePlanningResourceFeed(suite.client, { teams: { size: 0 }, refresh: 0, onChange: () => { notifications++ } })
22
+ await pause.entered
23
+ await suite.client.planning().close()
24
+ await feed.ready
25
+ const count = notifications
26
+ identity = 'organization-b'
27
+ const fresh = await suite.planning.teams.create({ name: 'New organization', fields: {} })
28
+ pause.release(); await tick(10)
29
+ expect(notifications).toBe(count)
30
+ expect(feed.seeded).toBe(false)
31
+ expect((await suite.client.planningStores!().cards.list({}, { size: 0 })).items).toEqual([])
32
+ expect((await suite.client.planningStores!().teams.list({}, { size: 0 })).items.map(team => team.id)).toEqual([fresh.id])
33
+ await suite.client.planning().close()
34
+ }
35
+ })
36
+
37
+ test('drains admitted authoritative deletions before clearing and rejects an old queued reconcile', async () => {
38
+ const suite = await makeSuite({ schemas: false })
39
+ const project = await suite.project()
40
+ const card = await suite.story(project.id!)
41
+ const stores = suite.client.planningStores!()
42
+ const feed = makePlanningFeed(suite.client, { query: { parent: project.id } })
43
+ await feed.ready
44
+ await suite.local.execute({ action: TransitionAction.Delete, card: card.id! }, { wait: true })
45
+ const pause = makeBarrier()
46
+ const purge = stores.cards.purge.bind(stores.cards)
47
+ stores.cards.purge = async (...args) => { await pause.hold(); return await purge(...args) }
48
+ const refreshing = feed.refresh()
49
+ await pause.entered
50
+ const queued = feed.refresh()
51
+ let stopped = false
52
+ const closing = suite.client.planning().close().then(() => { stopped = true })
53
+ await tick()
54
+ expect(stopped).toBe(false)
55
+ pause.release()
56
+ await Promise.all([closing, refreshing, queued])
57
+ stores.cards.purge = purge
58
+ expect((await stores.cards.list({}, { size: 0 })).items).toEqual([])
59
+ const fresh = await suite.planning.cards.get(project.id!)
60
+ await stores.cards.save(fresh)
61
+ await feed.refresh()
62
+ expect(await stores.cards.load(project.id!)).not.toBeNull()
63
+ await suite.client.planning().close()
64
+ })
65
+
66
+ test('a recordless socket frame never holds the mutation queue over its card RPC', async () => {
67
+ const pause = makeBarrier()
68
+ let delay = false
69
+ let carrier: Connection | undefined
70
+ const suite = await makeSuite({ schemas: false, socket: true, opening: async connection => { carrier = connection },
71
+ after: async call => { if (delay && call.alias === protocols.card.get.alias) await pause.hold() },
72
+ })
73
+ const project = await suite.project()
74
+ const card = await suite.story(project.id!)
75
+ const feed = makePlanningFeed(suite.client, { query: { parent: project.id } })
76
+ await feed.ready
77
+ await suite.local.execute({ action: TransitionAction.Update, card: card.id!, changes: { title: 'Updated on server' } }, { wait: true })
78
+ await tick()
79
+ const wire = structuredClone(suite.frames.find((frame: any) => frame.event === PLANNING_COMMIT_EVENT && frame.payload?.card === card.id && frame.payload.state === CommitState.Committed)) as { payload: { record?: unknown } }
80
+ expect(wire).toBeDefined()
81
+ delete wire.payload.record
82
+ delay = true
83
+ const receiving = carrier!.receive(JSON.stringify(wire))
84
+ await pause.entered
85
+ await suite.client.planning().close()
86
+ await receiving
87
+ pause.release(); await tick()
88
+ expect((await suite.client.planningStores!().cards.list({}, { size: 0 })).items).toEqual([])
89
+ expect((await suite.client.planningStores!().commits.list({}, { size: 0 })).items).toEqual([])
90
+ await feed.stop()
91
+ })
92
+ })
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { makeSuite, tick } from './context.js'
3
+ import { makeBarrier } from './lifecycle/context.js'
4
+
5
+ describe('@owlmeans/client-planning — socket generations', () => {
6
+ test('closes a late carrier and never restores its old subscription over a fresh connection', async () => {
7
+ const pause = makeBarrier()
8
+ let staleCloses = 0
9
+ const suite = await makeSuite({ schemas: false, socket: true, opening: async (connection, attempt) => {
10
+ if (attempt !== 1) return
11
+ const close = connection.close.bind(connection)
12
+ connection.close = async () => { staleCloses++; await close() }
13
+ await pause.hold()
14
+ } })
15
+ const source = suite.client.planning().commits
16
+ let oldCalls = 0, newCalls = 0
17
+ const old = source.subscribe(() => { oldCalls++ })
18
+ void Promise.resolve(old).catch(() => {})
19
+ await pause.entered
20
+ await source.close()
21
+ await expect(Promise.resolve(old)).rejects.toThrow('scope-changed')
22
+ const stop = await source.subscribe(() => { newCalls++ })
23
+ pause.release(); await tick(10)
24
+ expect(staleCloses).toBe(1)
25
+ expect(source.connected()).toBe(true)
26
+ await suite.project()
27
+ await tick(10)
28
+ expect(oldCalls).toBe(0)
29
+ expect(newCalls).toBeGreaterThan(0)
30
+ stop(); await suite.client.planning().close()
31
+ })
32
+
33
+ test('a stale opener failure cannot discard a new pending opener or duplicate its connection', async () => {
34
+ const oldPause = makeBarrier(), newPause = makeBarrier()
35
+ const suite = await makeSuite({ schemas: false, socket: true, opening: async (_connection, attempt) => {
36
+ if (attempt === 1) { await oldPause.hold(); throw new Error('Old carrier failed') }
37
+ if (attempt === 2) await newPause.hold()
38
+ } })
39
+ const source = suite.client.planning().commits
40
+ const old = source.subscribe(() => {})
41
+ void Promise.resolve(old).catch(() => {})
42
+ await oldPause.entered; await source.close()
43
+ await expect(Promise.resolve(old)).rejects.toThrow('scope-changed')
44
+ const next = source.subscribe(() => {})
45
+ await newPause.entered
46
+ oldPause.release(); await tick()
47
+ const joined = source.subscribe(() => {})
48
+ await tick()
49
+ expect(suite.sockets()).toBe(2)
50
+ newPause.release()
51
+ const stops = await Promise.all([next, joined])
52
+ expect(source.connected()).toBe(true)
53
+ stops.forEach(stop => stop())
54
+ await suite.client.planning().close()
55
+ })
56
+
57
+ test('close cancels an in-progress dispatch snapshot before it invokes another old listener', async () => {
58
+ const pause = makeBarrier()
59
+ const suite = await makeSuite({ schemas: false, socket: true })
60
+ const source = suite.client.planning().commits
61
+ let later = 0
62
+ await source.subscribe(async () => await pause.hold())
63
+ await source.subscribe(() => { later++ })
64
+ const writing = suite.project()
65
+ await pause.entered
66
+ await source.close()
67
+ pause.release(); await writing; await tick()
68
+ expect(later).toBe(0)
69
+ let fresh = 0
70
+ const stop = await source.subscribe(() => { fresh++ })
71
+ await suite.project('Fresh connection'); await tick()
72
+ expect(fresh).toBeGreaterThan(0)
73
+ expect(later).toBe(0)
74
+ stop(); await suite.client.planning().close()
75
+ })
76
+ })
@@ -0,0 +1,81 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { AssigneeKind, mentionHelper, TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
+ import { ENTITY_ID, makeSuite, STORY_FLOW } from './context.js'
4
+
5
+ test('stock HTTP and socket replies omit storage organization metadata and preserve usable client records', async () => {
6
+ const suite = await makeSuite({ resources: true, socket: true })
7
+ const p = suite.planning
8
+ const unsubscribe = await p.commits.subscribe(() => {})
9
+ const project = await suite.project()
10
+ const fields = { scope: { entityId: 'custom-field-value' }, card: { entityId: 'opaque-field-value' } }
11
+ const created = await p.execute({ action: TransitionAction.Create,
12
+ card: { kind: WorkcardKind.Project, type: project.type, title: 'Remote project', fields },
13
+ }, { wait: true })
14
+ expect(created.card?.entityId).toBe('')
15
+ expect(created.transition.entityId).toBe('')
16
+ expect(created.card?.fields).toEqual(fields)
17
+ expect((await suite.local.cards.get(created.card!.id!)).entityId).toBe(ENTITY_ID)
18
+ const card = await suite.story(project.id!)
19
+ await p.cards.get(card.id!)
20
+ await p.cards.list({ size: 0 })
21
+ await p.transitions.get(created.transition.id!)
22
+ await p.transitions.list({ card: created.card!.id! })
23
+ await p.commits.status(created.transition.id!)
24
+ const model = await p.model(project.id!)
25
+ await model.write('brief', '# Saved', { wait: true })
26
+ const specification = await p.specifications.current(project.id!, 'brief')
27
+ await p.specifications.get(specification!.id!)
28
+ await p.specifications.list(project.id!)
29
+ await p.specifications.revisions(specification!.id!)
30
+ const actor = await p.assignees.create({ nickname: 'wire-reader', kind: AssigneeKind.Human,
31
+ type: 'test:participant', authentication: { provider: 'external', externalId: 'subject' } })
32
+ const team = await p.teams.create({ name: 'Wire team' })
33
+ await p.teams.addMember(team.id!, actor.id!)
34
+ await p.teams.attach(team.id!, project.id!)
35
+ for (const name of ['assignees', 'teams'] as const) {
36
+ const id = name === 'assignees' ? actor.id! : team.id!
37
+ await p[name].get(id)
38
+ await p[name].list()
39
+ }
40
+ expect((await p.teams.members(team.id!))[0]?.entityId).toBe('')
41
+ expect((await p.teams.assignees(project.id!))[0]?.id).toBe(actor.id)
42
+ expect(await p.teams.projects(team.id!)).toEqual([project.id!])
43
+ const comment = await p.comments.create({ card: card.id!, body: mentionHelper.encode(actor.id!, actor.nickname) })
44
+ await p.comments.get(comment.id!)
45
+ await p.comments.list()
46
+ const mentions = await p.mentions.list()
47
+ await p.mentions.get(mentions.items[0]!.id!)
48
+ await p.mentions.rebuild(comment.id!)
49
+ await p.relationships.list()
50
+ const definitions = await p.definitions!.define({ types: [{ type: 'test:wire', kind: WorkcardKind.Card,
51
+ version: 1, fields: { type: 'object', properties: { scope: { type: 'object', properties: { entityId: { type: 'string' } } } } },
52
+ flows: [STORY_FLOW], specifications: [],
53
+ }] }, { project: project.id })
54
+ expect(definitions[0]?.entityId).toBe('')
55
+ expect((await p.definitions!.bundle(project.id)).scope?.entityId).toBe('')
56
+
57
+ // Check the actual server responses before the client hydrates its advisory metadata.
58
+ for (const call of suite.calls) {
59
+ expect(JSON.stringify(call.reply) ?? '').not.toContain(ENTITY_ID)
60
+ const check = (value: unknown): void => {
61
+ if (Array.isArray(value)) { value.forEach(check); return }
62
+ if (value == null || typeof value !== 'object') return
63
+ const object = value as Record<string, unknown>
64
+ expect(object).not.toHaveProperty('entityId')
65
+ for (const key of ['items', 'records', 'card', 'record', 'transition', 'bundle', 'scope']) check(object[key])
66
+ }
67
+ check(call.reply)
68
+ }
69
+ const frames = suite.frames as Array<{ payload?: Record<string, unknown> }>
70
+ const commitFrames = frames.filter(frame => frame.payload?.transition != null)
71
+ expect(commitFrames.length).toBeGreaterThan(0)
72
+ for (const frame of commitFrames) {
73
+ expect(JSON.stringify(frame)).not.toContain(ENTITY_ID)
74
+ expect(frame.payload).not.toHaveProperty('entityId')
75
+ if (frame.payload?.record != null) expect(frame.payload.record).not.toHaveProperty('entityId')
76
+ }
77
+ expect((await suite.client.planningStores!().assignees.get(actor.id!)).entityId).toBe('')
78
+ expect((await suite.local.assignees.get(actor.id!)).entityId).toBe(ENTITY_ID)
79
+ unsubscribe()
80
+ await suite.client.planning().close()
81
+ })
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AssigneeKind, mentionHelper, WorkcardConflict } from '@owlmeans/planning'
3
+ import { makePlanningResourceFeed } from '../src/index.js'
4
+ import { ENTITY_ID, makeSuite } from './context.js'
5
+
6
+ describe('@owlmeans/client-planning — separate resources over real protocol handlers', () => {
7
+ test('active assignee feeds include unset flags and reconcile removal without dropping retired history', async () => {
8
+ const suite = await makeSuite({ resources: true })
9
+ const draft = (nickname: string) => ({ nickname, kind: AssigneeKind.Human, type: 'test:participant', authentication: { provider: 'external', externalId: nickname } })
10
+ const active = await suite.local.assignees.create(draft('active'))
11
+ const removed = await suite.local.assignees.create(draft('removed'))
12
+ const retired = await suite.local.assignees.create(draft('retired'))
13
+ await suite.local.assignees.retire(retired.id!, { version: 1 })
14
+ await suite.planning.assignees.get(retired.id!)
15
+ const stores = suite.client.planningStores!()
16
+ const ids = [active.id!, removed.id!, retired.id!]
17
+ const feed = makePlanningResourceFeed(suite.client, { assignees: { ids, retired: false, size: 0 }, refresh: 0 })
18
+ await feed.ready
19
+ expect(feed.error).toBeNull()
20
+ expect((await suite.planning.assignees.list({ ids, retired: false })).items.map(row => row.id).sort()).toEqual([active.id!, removed.id!].sort())
21
+ expect(await stores.assignees.load(active.id!)).not.toBeNull()
22
+ expect(await stores.assignees.load(removed.id!)).not.toBeNull()
23
+ // A remote retirement removes the formerly-active snapshot, but unrelated historical
24
+ // identities already in the shared mirror remain available for existing references.
25
+ await suite.local.assignees.retire(removed.id!, { version: 1 })
26
+ await feed.refresh()
27
+ expect(await stores.assignees.load(removed.id!)).toBeNull()
28
+ expect((await stores.assignees.get(retired.id!)).retired).toBe(true)
29
+ expect(await stores.assignees.load(active.id!)).not.toBeNull()
30
+ await feed.stop(); await suite.client.planning().close()
31
+ })
32
+
33
+ test('assignees, teams, comments and mentions roundtrip and mirror versioned writes', async () => {
34
+ const suite = await makeSuite({ resources: true })
35
+ const p = suite.planning, project = await suite.project(), card = await suite.story(project.id!)
36
+ const actor = await p.assignees.create({ nickname: 'reader', kind: AssigneeKind.Human, type: 'test:participant', authentication: { provider: 'external', externalId: 'reader-id' } })
37
+ const renamed = await p.assignees.update(actor.id!, { nickname: 'renamed' }, { version: 1 })
38
+ expect(renamed.id).toBe(actor.id)
39
+ await expect(p.assignees.update(actor.id!, { nickname: 'stale' }, { version: 1 })).rejects.toBeInstanceOf(WorkcardConflict)
40
+ const team = await p.teams.create({ name: 'Editors', externalId: 'external:group' })
41
+ await p.teams.addMember(team.id!, actor.id!); await p.teams.attach(team.id!, project.id!)
42
+ expect((await p.teams.assignees(project.id!)).map(row => row.id)).toEqual([actor.id])
43
+ const comment = await p.comments.create({ card: card.id!, body: mentionHelper.encode(actor.id!, actor.nickname) })
44
+ expect(comment.author).toBe(suite.local.scope.assigneeId!)
45
+ const mentions = await p.mentions.list({ comment: comment.id })
46
+ expect(mentions.items.map(row => row.assignee)).toEqual([actor.id!])
47
+ expect((await p.mentions.get(mentions.items[0].id!)).revision).toBe(1)
48
+ await p.comments.update(comment.id!, { body: 'no mentions' }, { version: 1 })
49
+ expect(await p.mentions.load(mentions.items[0].id!)).toBeNull()
50
+ const stores = suite.client.planningStores!()
51
+ expect((await stores.assignees.get(actor.id!)).version).toBe(2)
52
+ expect((await stores.comments.get(comment.id!)).version).toBe(2)
53
+ await p.comments.remove(comment.id!, { version: 2 })
54
+ expect(await stores.comments.load(comment.id!)).toBeNull()
55
+ const query = suite.calls.find(call => call.alias.endsWith(':mentions:list'))?.query
56
+ expect(typeof query?.query).toBe('string')
57
+ await suite.client.planning().close()
58
+ })
59
+
60
+ test('a delayed seed retains a local version written after the snapshot began', async () => {
61
+ let release: (() => void) | undefined, entered: (() => void) | undefined
62
+ const arrived = new Promise<void>(resolve => { entered = resolve })
63
+ const held = new Promise<void>(resolve => { release = resolve })
64
+ let delay = false
65
+ const suite = await makeSuite({ resources: true, after: async call => {
66
+ if (delay && call.alias.endsWith(':teams:list')) { delay = false; entered!(); await held }
67
+ } })
68
+ const p = suite.planning, stores = suite.client.planningStores!()
69
+ const team = await p.teams.create({ name: 'Before' })
70
+ delay = true
71
+ const feed = makePlanningResourceFeed(suite.client, { scope: { entityId: ENTITY_ID }, teams: { size: 0 }, refresh: 0 })
72
+ await arrived
73
+ await p.teams.update(team.id!, { name: 'After' }, { version: 1 })
74
+ const added = await p.teams.create({ name: 'New during seed' })
75
+ release!(); await feed.ready
76
+ expect((await stores.teams.get(team.id!)).name).toBe('After')
77
+ expect((await stores.teams.get(added.id!)).name).toBe('New during seed')
78
+ await feed.stop(); await suite.client.planning().close()
79
+ })
80
+
81
+ test('scope changes reject stale reads and close clears registered feeds and mirrors', async () => {
82
+ let identity = 'old-session', release: (() => void) | undefined, entered: (() => void) | undefined
83
+ const held = new Promise<void>(resolve => { release = resolve }), arrived = new Promise<void>(resolve => { entered = resolve })
84
+ let delay = false
85
+ const suite = await makeSuite({ resources: true, scopeKey: () => identity, after: async call => {
86
+ if (delay && call.alias.endsWith(':teams:get')) { delay = false; entered!(); await held }
87
+ } })
88
+ const team = await suite.local.teams.create({ name: 'Old identity' })
89
+ delay = true
90
+ const reading = suite.planning.teams.get(team.id!)
91
+ await arrived; identity = 'new-session'; release!()
92
+ await expect(reading).rejects.toThrow('scope-changed')
93
+ expect(await suite.client.planningStores!().teams.load(team.id!)).toBeNull()
94
+ const feed = makePlanningResourceFeed(suite.client, { teams: { size: 0 }, refresh: 0 })
95
+ await feed.ready
96
+ expect(await suite.client.planningStores!().teams.load(team.id!)).not.toBeNull()
97
+ await suite.client.planning().close()
98
+ expect(await suite.client.planningStores!().teams.load(team.id!)).toBeNull()
99
+ await feed.refresh()
100
+ expect(await suite.client.planningStores!().teams.load(team.id!)).toBeNull()
101
+ })
102
+
103
+ test('stopping an in-flight resource feed prevents old tenant results from publishing', async () => {
104
+ let release: (() => void) | undefined, entered: (() => void) | undefined
105
+ const held = new Promise<void>(resolve => { release = resolve }), arrived = new Promise<void>(resolve => { entered = resolve })
106
+ const suite = await makeSuite({ resources: true, after: async call => {
107
+ if (call.alias.endsWith(':teams:list')) { entered!(); await held }
108
+ } })
109
+ const team = await suite.local.teams.create({ name: 'Old organization' })
110
+ const feed = makePlanningResourceFeed(suite.client, { teams: { size: 0 }, refresh: 0 })
111
+ await arrived
112
+ const stopping = feed.stop(); release!(); await stopping
113
+ expect(await suite.client.planningStores!().teams.load(team.id!)).toBeNull()
114
+ await suite.client.planning().close()
115
+ })
116
+ })
@@ -1,20 +1,21 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { isPending, modelOf, TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
- import { applyCards, syncCards } from '../src/index.js'
2
+ import { cardHelper, modelOf, TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
+ import { planningMirrorOf, syncHelper } from '../src/index.js'
4
4
  import { makeSuite } from './context.js'
5
5
 
6
6
  describe('@owlmeans/client-planning — the state mirror', () => {
7
7
  test('syncCards drops only what matches `where` and the list does not name', async () => {
8
8
  const suite = await makeSuite()
9
9
  const stores = suite.client.planningStores!()
10
+ const mirror = planningMirrorOf(stores)
10
11
  const one = await suite.project('One')
11
12
  const two = await suite.project('Two')
12
13
  const kept = await suite.story(one.id!, 'Kept')
13
14
  const deleted = await suite.story(one.id!, 'Deleted elsewhere')
14
15
  const elsewhere = await suite.story(two.id!, 'Another project')
15
- await applyCards(stores, [one, two, kept, deleted, elsewhere])
16
+ await mirror.applyCards([one, two, kept, deleted, elsewhere])
16
17
 
17
- await syncCards(stores.cards, [kept], { parent: one.id })
18
+ await syncHelper.syncCards(stores.cards, [kept], { parent: one.id })
18
19
 
19
20
  const ids = (await stores.cards.list()).items.map(card => card.id).sort()
20
21
  expect(ids).toEqual([one.id, two.id, kept.id, elsewhere.id].sort())
@@ -32,11 +33,11 @@ describe('@owlmeans/client-planning — the state mirror', () => {
32
33
  suite.planning.specifications.list(project.id!),
33
34
  suite.planning.cards.list({ kind: WorkcardKind.Card, parent: project.id }),
34
35
  ])
35
- await syncCards(stores.cards, projects.items, { kind: WorkcardKind.Project })
36
- await syncCards(stores.cards, specs.items, { kind: WorkcardKind.Specification, parent: project.id })
36
+ await syncHelper.syncCards(stores.cards, projects.items, { kind: WorkcardKind.Project })
37
+ await syncHelper.syncCards(stores.cards, specs.items, { kind: WorkcardKind.Specification, parent: project.id })
37
38
  // A card list reloading with nothing in it must not take the project or its brief with it.
38
- await syncCards(stores.cards, [], { kind: WorkcardKind.Card, parent: project.id })
39
- await syncCards(stores.cards, cards.items, { kind: WorkcardKind.Card, parent: project.id })
39
+ await syncHelper.syncCards(stores.cards, [], { kind: WorkcardKind.Card, parent: project.id })
40
+ await syncHelper.syncCards(stores.cards, cards.items, { kind: WorkcardKind.Card, parent: project.id })
40
41
 
41
42
  const kinds = (await stores.cards.list()).items.map(card => card.kind).sort()
42
43
  expect(kinds).toEqual([WorkcardKind.Card, WorkcardKind.Project, WorkcardKind.Specification])
@@ -45,23 +46,24 @@ describe('@owlmeans/client-planning — the state mirror', () => {
45
46
  test('a receipt marks the card pending (head > seq) and its commit clears the mark', async () => {
46
47
  const suite = await makeSuite({ sync: false })
47
48
  const stores = suite.client.planningStores!()
49
+ const mirror = planningMirrorOf(stores)
48
50
  const project = await suite.project()
49
51
  const story = await suite.story(project.id!)
50
- await applyCards(stores, [story])
52
+ await mirror.applyCards([story])
51
53
 
52
54
  const receipt = await suite.planning.execute({
53
55
  card: story.id!, action: TransitionAction.Transit, transition: 'start', expectSeq: story.seq,
54
56
  })
55
57
 
56
58
  const marked = await stores.cards.get(story.id!)
57
- expect(isPending(marked)).toBe(true)
59
+ expect(cardHelper.isPending(marked)).toBe(true)
58
60
  expect(modelOf(marked, suite.planning).pending()).toBe(true)
59
61
 
60
62
  await suite.store.flush()
61
63
  await receipt.committed({ timeout: 5_000 })
62
64
 
63
65
  const folded = await stores.cards.get(story.id!)
64
- expect(isPending(folded)).toBe(false)
66
+ expect(cardHelper.isPending(folded)).toBe(false)
65
67
  expect(folded.status).toBe('doing')
66
68
  })
67
69
  })