@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.
- package/README.md +94 -11
- package/agent-meta/manifest.json +62 -4
- package/agent-meta/skills/client-planning/SKILL.md +60 -78
- package/agent-meta/skills/client-planning/parts/aux-mirrors.md +61 -0
- package/agent-meta/skills/client-planning/parts/mirror.md +67 -0
- package/agent-meta/skills/client-planning/parts/validation.md +60 -0
- package/build/client.d.ts +1 -1
- package/build/client.d.ts.map +1 -1
- package/build/client.js +66 -14
- package/build/client.js.map +1 -1
- package/build/commits.d.ts +2 -13
- package/build/commits.d.ts.map +1 -1
- package/build/commits.js +190 -132
- package/build/commits.js.map +1 -1
- package/build/consts.d.ts +4 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +10 -4
- package/build/consts.js.map +1 -1
- package/build/definitions.d.ts +13 -0
- package/build/definitions.d.ts.map +1 -0
- package/build/definitions.js +70 -0
- package/build/definitions.js.map +1 -0
- package/build/events/types.d.ts +34 -0
- package/build/events/types.d.ts.map +1 -0
- package/build/events/types.js +2 -0
- package/build/events/types.js.map +1 -0
- package/build/events.d.ts +10 -21
- package/build/events.d.ts.map +1 -1
- package/build/events.js +104 -102
- package/build/events.js.map +1 -1
- package/build/facade.d.ts.map +1 -1
- package/build/facade.js +86 -49
- package/build/facade.js.map +1 -1
- package/build/feed.d.ts +4 -4
- package/build/feed.d.ts.map +1 -1
- package/build/feed.js +50 -27
- package/build/feed.js.map +1 -1
- package/build/helper/types.d.ts +15 -0
- package/build/helper/types.d.ts.map +1 -0
- package/build/helper/types.js +2 -0
- package/build/helper/types.js.map +1 -0
- package/build/helper.d.ts +7 -7
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +16 -8
- package/build/helper.js.map +1 -1
- package/build/index.d.ts +11 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +6 -0
- package/build/index.js.map +1 -1
- package/build/lifecycle/types.d.ts +27 -0
- package/build/lifecycle/types.d.ts.map +1 -0
- package/build/lifecycle/types.js +2 -0
- package/build/lifecycle/types.js.map +1 -0
- package/build/lifecycle.d.ts +3 -0
- package/build/lifecycle.d.ts.map +1 -0
- package/build/lifecycle.js +157 -0
- package/build/lifecycle.js.map +1 -0
- package/build/resource-feed.d.ts +5 -0
- package/build/resource-feed.d.ts.map +1 -0
- package/build/resource-feed.js +97 -0
- package/build/resource-feed.js.map +1 -0
- package/build/resources.d.ts +6 -0
- package/build/resources.d.ts.map +1 -0
- package/build/resources.js +93 -0
- package/build/resources.js.map +1 -0
- package/build/stores.d.ts +4 -11
- package/build/stores.d.ts.map +1 -1
- package/build/stores.js +11 -35
- package/build/stores.js.map +1 -1
- package/build/sync/types.d.ts +18 -0
- package/build/sync/types.d.ts.map +1 -0
- package/build/sync/types.js +2 -0
- package/build/sync/types.js.map +1 -0
- package/build/sync.d.ts +4 -0
- package/build/sync.d.ts.map +1 -0
- package/build/sync.js +26 -0
- package/build/sync.js.map +1 -0
- package/build/types.d.ts +48 -3
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +13 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/record/types.d.ts +28 -0
- package/build/utils/record/types.d.ts.map +1 -0
- package/build/utils/record/types.js +2 -0
- package/build/utils/record/types.js.map +1 -0
- package/build/utils/record.d.ts +3 -24
- package/build/utils/record.d.ts.map +1 -1
- package/build/utils/record.js +64 -70
- package/build/utils/record.js.map +1 -1
- package/build/validation/consts.d.ts +23 -0
- package/build/validation/consts.d.ts.map +1 -0
- package/build/validation/consts.js +289 -0
- package/build/validation/consts.js.map +1 -0
- package/build/validation/types.d.ts +17 -0
- package/build/validation/types.d.ts.map +1 -0
- package/build/validation/types.js +2 -0
- package/build/validation/types.js.map +1 -0
- package/build/validation.d.ts +10 -0
- package/build/validation.d.ts.map +1 -0
- package/build/validation.js +171 -0
- package/build/validation.js.map +1 -0
- package/package.json +20 -18
- package/src/client.ts +58 -15
- package/src/commits.ts +152 -184
- package/src/consts.ts +12 -4
- package/src/definitions.ts +93 -0
- package/src/events/types.ts +35 -0
- package/src/events.ts +118 -108
- package/src/facade.ts +87 -55
- package/src/feed.ts +47 -28
- package/src/helper/types.ts +15 -0
- package/src/helper.ts +27 -10
- package/src/index.ts +12 -0
- package/src/lifecycle/types.ts +28 -0
- package/src/lifecycle.ts +123 -0
- package/src/resource-feed.ts +79 -0
- package/src/resources.ts +84 -0
- package/src/stores.ts +11 -40
- package/src/sync/types.ts +18 -0
- package/src/sync.ts +40 -0
- package/src/types.local.ts +14 -0
- package/src/types.ts +53 -5
- package/src/utils/record/types.ts +27 -0
- package/src/utils/record.ts +68 -71
- package/src/validation/consts.ts +289 -0
- package/src/validation/types.ts +18 -0
- package/src/validation.ts +140 -0
- package/tests/app.spec.ts +72 -0
- package/tests/context.ts +46 -15
- package/tests/csp.spec.ts +119 -0
- package/tests/definitions.spec.ts +74 -0
- package/tests/draft7.spec.ts +13 -0
- package/tests/events.spec.ts +13 -9
- package/tests/facade.spec.ts +2 -1
- package/tests/lifecycle/context.ts +29 -0
- package/tests/lifecycle-close.spec.ts +103 -0
- package/tests/lifecycle-commits.spec.ts +92 -0
- package/tests/lifecycle-definitions.spec.ts +28 -0
- package/tests/lifecycle-feeds.spec.ts +92 -0
- package/tests/lifecycle-sockets.spec.ts +76 -0
- package/tests/replies.spec.ts +81 -0
- package/tests/resources.spec.ts +116 -0
- package/tests/stores.spec.ts +13 -11
- 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
|
+
})
|
package/tests/stores.spec.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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(
|
|
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(
|
|
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
|
})
|