@owlmeans/client-planning 0.1.18-rc.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 (67) hide show
  1. package/README.md +128 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/client-planning/SKILL.md +212 -0
  4. package/build/client.d.ts +26 -0
  5. package/build/client.d.ts.map +1 -0
  6. package/build/client.js +120 -0
  7. package/build/client.js.map +1 -0
  8. package/build/commits.d.ts +17 -0
  9. package/build/commits.d.ts.map +1 -0
  10. package/build/commits.js +205 -0
  11. package/build/commits.js.map +1 -0
  12. package/build/consts.d.ts +27 -0
  13. package/build/consts.d.ts.map +1 -0
  14. package/build/consts.js +29 -0
  15. package/build/consts.js.map +1 -0
  16. package/build/events.d.ts +27 -0
  17. package/build/events.d.ts.map +1 -0
  18. package/build/events.js +109 -0
  19. package/build/events.js.map +1 -0
  20. package/build/facade.d.ts +19 -0
  21. package/build/facade.d.ts.map +1 -0
  22. package/build/facade.js +130 -0
  23. package/build/facade.js.map +1 -0
  24. package/build/feed.d.ts +18 -0
  25. package/build/feed.d.ts.map +1 -0
  26. package/build/feed.js +123 -0
  27. package/build/feed.js.map +1 -0
  28. package/build/helper.d.ts +11 -0
  29. package/build/helper.d.ts.map +1 -0
  30. package/build/helper.js +10 -0
  31. package/build/helper.js.map +1 -0
  32. package/build/index.d.ts +10 -0
  33. package/build/index.d.ts.map +1 -0
  34. package/build/index.js +9 -0
  35. package/build/index.js.map +1 -0
  36. package/build/stores.d.ts +28 -0
  37. package/build/stores.d.ts.map +1 -0
  38. package/build/stores.js +61 -0
  39. package/build/stores.js.map +1 -0
  40. package/build/types.d.ts +147 -0
  41. package/build/types.d.ts.map +1 -0
  42. package/build/types.js +2 -0
  43. package/build/types.js.map +1 -0
  44. package/build/utils/record.d.ts +25 -0
  45. package/build/utils/record.d.ts.map +1 -0
  46. package/build/utils/record.js +72 -0
  47. package/build/utils/record.js.map +1 -0
  48. package/package.json +55 -0
  49. package/src/client.ts +153 -0
  50. package/src/commits.ts +247 -0
  51. package/src/consts.ts +38 -0
  52. package/src/events.ts +121 -0
  53. package/src/facade.ts +169 -0
  54. package/src/feed.ts +131 -0
  55. package/src/helper.ts +18 -0
  56. package/src/index.ts +10 -0
  57. package/src/stores.ts +81 -0
  58. package/src/types.ts +163 -0
  59. package/src/utils/record.ts +91 -0
  60. package/tests/commits.spec.ts +67 -0
  61. package/tests/context.ts +301 -0
  62. package/tests/events.spec.ts +95 -0
  63. package/tests/facade.spec.ts +83 -0
  64. package/tests/feed.spec.ts +39 -0
  65. package/tests/stores.spec.ts +67 -0
  66. package/tests/tsconfig.json +17 -0
  67. package/tsconfig.json +15 -0
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { CommitState, CommitTimeout, TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
+ import { STORY, makeSuite, protocols, tick } from './context.js'
4
+ import type { Suite } from './context.js'
5
+
6
+ const draft = (suite: Suite, parent: string) => suite.planning.execute({
7
+ card: { kind: WorkcardKind.Card, type: STORY, parent, title: 'Waited for' },
8
+ action: TransitionAction.Create,
9
+ })
10
+
11
+ describe('@owlmeans/client-planning — waiting for a commit', () => {
12
+ test('wait subscribes before its first read, so a commit landing right after that read is caught', async () => {
13
+ let transition: string | undefined
14
+ let landed = false
15
+ const suite: Suite = await makeSuite({
16
+ sync: false,
17
+ socket: true,
18
+ poll: 20,
19
+ // The server folds the moment it has answered the first status read — after the read, before
20
+ // anything else the client does. Only a subscription made BEFORE the read can hear it.
21
+ after: async call => {
22
+ if (!landed && call.alias === protocols.commit.get.alias && transition != null) {
23
+ landed = true
24
+ await suite.store.flush()
25
+ await tick(10)
26
+ }
27
+ },
28
+ })
29
+ const project = await suite.project()
30
+ const receipt = await draft(suite, project.id!)
31
+ transition = receipt.transition.id
32
+ const before = suite.calls.length
33
+
34
+ const card = await suite.planning.commits.wait(transition!, { timeout: 5_000 })
35
+
36
+ expect(card?.title).toBe('Waited for')
37
+ expect(suite.sockets()).toBe(1)
38
+ // One status read and nothing else: the frame answered, not a long poll.
39
+ expect(suite.calls.slice(before).filter(call => call.alias === protocols.commit.get.alias)).toHaveLength(1)
40
+ })
41
+
42
+ test('without a socket the long poll alone brings the commit', async () => {
43
+ const suite = await makeSuite({ sync: false, poll: 2 })
44
+ const project = await suite.project()
45
+ const receipt = await draft(suite, project.id!)
46
+
47
+ setTimeout(() => { void suite.store.flush() }, 300)
48
+ const card = await suite.planning.commits.wait(receipt.transition.id!, { timeout: 5_000 })
49
+
50
+ expect(card?.title).toBe('Waited for')
51
+ expect(suite.client.planning().commits.connected()).toBe(false)
52
+ expect(suite.calls.some(call => call.alias === protocols.commit.get.alias && Number(call.query.wait) > 0)).toBe(true)
53
+ })
54
+
55
+ test('the deadline throws CommitTimeout and leaves the transition pending', async () => {
56
+ const suite = await makeSuite({ sync: false, poll: 1 })
57
+ const project = await suite.project()
58
+ const receipt = await draft(suite, project.id!)
59
+
60
+ const started = Date.now()
61
+ await expect(suite.planning.commits.wait(receipt.transition.id!, { timeout: 1_200 }))
62
+ .rejects.toBeInstanceOf(CommitTimeout)
63
+ expect(Date.now() - started).toBeLessThan(3_000)
64
+
65
+ expect((await suite.planning.commits.status(receipt.transition.id!)).state).toBe(CommitState.Pending)
66
+ })
67
+ })
@@ -0,0 +1,301 @@
1
+ import type { Auth } from '@owlmeans/auth'
2
+ import { makeClientContext } from '@owlmeans/client-context'
3
+ import type { ClientConfig, ClientContext } from '@owlmeans/client-context'
4
+ import { AppType, createService, makeBasicContext } from '@owlmeans/context'
5
+ import type { BasicConfig, BasicContext, BasicEntrypoint } from '@owlmeans/context'
6
+ import { EntrypointOutcome, provideResponse, transportAlias } from '@owlmeans/entrypoint'
7
+ import type {
8
+ AbstractRequest, AbstractResponse, EntrypointHandler, EntrypointTransport, ResolvedEntity,
9
+ } from '@owlmeans/entrypoint'
10
+ import { ResilientError } from '@owlmeans/error'
11
+ import {
12
+ CodeScope, CodeStyle, IntrinsicStatus, makePlanningProtocols, SpecificationFormat, TransitionAction,
13
+ WorkcardKind,
14
+ } from '@owlmeans/planning'
15
+ import type {
16
+ AnyTypeSchema, PlanningFacade, PlanningSchemaBundle, PlanningService, Project, StatusFlowSchema,
17
+ TransitionReceipt, Workcard,
18
+ } from '@owlmeans/planning'
19
+ import { RouteProtocols } from '@owlmeans/route'
20
+ import { appendPlanningService, servePlanningEntrypoints } from '@owlmeans/server-planning'
21
+ import { makeMemoryPlanningStore } from '@owlmeans/server-planning/store'
22
+ import type { MemoryPlanningStore } from '@owlmeans/server-planning/store'
23
+ import { createBasicConnection } from '@owlmeans/socket'
24
+ import type { Connection } from '@owlmeans/socket'
25
+ import { appendPlanningClient, appendPlanningStores } from '../src/index.js'
26
+ import type { PlanningClientOptions, WithPlanningClient, WithPlanningStores } from '../src/index.js'
27
+
28
+ /**
29
+ * The whole suite runs a REAL `@owlmeans/server-planning` behind the client, with no network.
30
+ *
31
+ * Two contexts are built, as two processes would build them: a server context with
32
+ * `appendPlanningService` over the memory store and the handlers `servePlanningEntrypoints` binds,
33
+ * and a client context with `appendPlanningClient`. What connects them is the framework's own
34
+ * transport seam — a service under `transportAlias('http')` takes every client call — and it does
35
+ * what the HTTP hop does to a call, no more: the request crosses as JSON, a query value that is not a
36
+ * scalar is refused (axios would write `key[]=` and fastify would read that key literally), the
37
+ * request reaches the SERVER handler with the authenticated entity and profile the HTTP boundary
38
+ * would attach, and a refusal crosses as the marshalled text the API client unmarshals. The commit
39
+ * socket is the server's own `commit.events` handler over an in-memory socket pair: the server side
40
+ * is `@owlmeans/server-socket`'s connection over a WebSocket-shaped pipe, the client side a
41
+ * `@owlmeans/socket` connection reading the same frames.
42
+ */
43
+
44
+ export const ENTITY_ID = 'entity-acme'
45
+ export const OTHER_ENTITY_ID = 'entity-other'
46
+ export const PROFILE_ID = 'profile-1'
47
+
48
+ export const STORY_FLOW = 'test:story-flow'
49
+ export const PROJECT_FLOW = 'test:project-flow'
50
+ export const PROJECT = 'test:project'
51
+ export const STORY = 'test:story'
52
+ export const SPEC = 'test:spec'
53
+
54
+ const flows: StatusFlowSchema[] = [
55
+ {
56
+ id: STORY_FLOW,
57
+ version: 1,
58
+ statuses: [
59
+ { key: 'todo', intrinsic: IntrinsicStatus.Planned, initial: true },
60
+ { key: 'doing', intrinsic: IntrinsicStatus.InProgress },
61
+ { key: 'done', intrinsic: IntrinsicStatus.Closed, terminal: true },
62
+ ],
63
+ transitions: [
64
+ { name: 'start', from: ['todo'], to: 'doing', explicit: true },
65
+ { name: 'finish', from: ['doing'], to: 'done', explicit: true },
66
+ { name: 'reset', from: '*', to: 'todo' },
67
+ ],
68
+ },
69
+ {
70
+ id: PROJECT_FLOW,
71
+ version: 1,
72
+ statuses: [
73
+ { key: 'open', intrinsic: IntrinsicStatus.InProgress, initial: true },
74
+ { key: 'archived', intrinsic: IntrinsicStatus.Closed, terminal: true },
75
+ ],
76
+ transitions: [{ name: 'archive', from: ['open'], to: 'archived' }],
77
+ },
78
+ ]
79
+
80
+ const types: AnyTypeSchema[] = [
81
+ {
82
+ type: PROJECT,
83
+ kind: WorkcardKind.Project,
84
+ version: 1,
85
+ fields: { type: 'object', additionalProperties: true },
86
+ flows: [PROJECT_FLOW],
87
+ specifications: [{ category: 'brief', format: SpecificationFormat.Markdown, revisioned: true, type: SPEC }],
88
+ cardTypes: [STORY],
89
+ },
90
+ {
91
+ type: STORY,
92
+ kind: WorkcardKind.Card,
93
+ version: 1,
94
+ fields: {
95
+ type: 'object',
96
+ properties: { area: { type: 'string' } },
97
+ additionalProperties: false,
98
+ },
99
+ flows: [STORY_FLOW],
100
+ specifications: [],
101
+ relationships: [{ name: 'follows' }],
102
+ code: { prefix: 'ST-', style: CodeStyle.Random, length: 5, uppercase: true, uniqueWithin: CodeScope.Parent },
103
+ },
104
+ {
105
+ type: SPEC,
106
+ kind: WorkcardKind.Specification,
107
+ version: 1,
108
+ fields: { type: 'object', additionalProperties: true },
109
+ flows: [STORY_FLOW],
110
+ specifications: [],
111
+ },
112
+ ]
113
+
114
+ export const SCHEMAS: PlanningSchemaBundle = { version: 1, types, flows }
115
+
116
+ export const protocols = makePlanningProtocols({
117
+ base: { alias: 'test:planning', path: '/planning' },
118
+ guards: [],
119
+ })
120
+
121
+ const AUTH = { profileId: PROFILE_ID, userId: 'user-1', entityId: 'acme' } as unknown as Auth
122
+ const entityOf = (id: string): ResolvedEntity => ({ id, slug: id, iamKey: id })
123
+
124
+ /** What crossed the transport, per call — the specs read it to prove which route answered. */
125
+ export interface Call {
126
+ alias: string
127
+ query: Record<string, unknown>
128
+ }
129
+
130
+ export interface SuiteOptions extends Partial<Pick<PlanningClientOptions, 'poll' | 'schemas'>> {
131
+ /** The server folds inside `execute` (default) or only on `store.flush()`. */
132
+ sync?: boolean
133
+ /** Give the client a commit socket. */
134
+ socket?: boolean
135
+ /** Register the state mirror on the client. */
136
+ stores?: boolean
137
+ /** The entity the transport authenticates as. */
138
+ entityId?: string
139
+ /** Runs after the server answered a call and before the client sees the answer. */
140
+ after?: (call: Call) => Promise<void>
141
+ }
142
+
143
+ export type ClientCtx = ClientContext<ClientConfig> & WithPlanningClient & Partial<WithPlanningStores>
144
+
145
+ export interface Suite {
146
+ server: BasicContext<BasicConfig>
147
+ store: MemoryPlanningStore
148
+ /** The server's own facade for the transport's entity — the ground truth a spec compares with. */
149
+ local: PlanningFacade
150
+ client: ClientCtx
151
+ /** The client's facade. */
152
+ planning: PlanningFacade
153
+ calls: Call[]
154
+ sockets: () => number
155
+ project: (title?: string) => Promise<Project>
156
+ story: (project: string, title?: string, fields?: Record<string, unknown>) => Promise<Workcard>
157
+ }
158
+
159
+ const wire = <T>(value: T): T => value === undefined ? value : JSON.parse(JSON.stringify(value)) as T
160
+
161
+ type Handled = { handle?: EntrypointHandler }
162
+
163
+ /** A WebSocket-shaped pipe for the server handler, wired to a client connection reading its frames. */
164
+ const socketPair = (): { server: unknown, client: Connection } => {
165
+ const handlers = new Map<string, Set<(...args: unknown[]) => unknown>>()
166
+ const emit = async (event: string, ...args: unknown[]): Promise<void> => {
167
+ for (const handler of [...(handlers.get(event) ?? [])]) {
168
+ await handler(...args)
169
+ }
170
+ }
171
+ const client = createBasicConnection()
172
+ const server = {
173
+ send: async (data: string) => { await client.receive(data) },
174
+ close: async () => { await emit('close', 1000) },
175
+ on: (event: string, handler: (...args: unknown[]) => unknown) => {
176
+ handlers.set(event, (handlers.get(event) ?? new Set()).add(handler))
177
+ },
178
+ off: (event: string, handler: (...args: unknown[]) => unknown) => { handlers.get(event)?.delete(handler) },
179
+ ping: () => { },
180
+ pong: () => { },
181
+ }
182
+ client.send = async message => {
183
+ await emit('message', Buffer.from(typeof message === 'string' ? message : JSON.stringify(message)))
184
+ }
185
+ client.close = async () => { await emit('close', 1000) }
186
+
187
+ return { server, client }
188
+ }
189
+
190
+ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
191
+ const entityId = opts.entityId ?? ENTITY_ID
192
+
193
+ const server = makeBasicContext<BasicConfig>({
194
+ ready: false, service: 'client-planning-tests-server', type: AppType.Backend,
195
+ } as BasicConfig)
196
+ const store = makeMemoryPlanningStore({ sync: opts.sync !== false })
197
+ appendPlanningService(server, { store, schemas: { types, flows } })
198
+ server.registerEntrypoints(servePlanningEntrypoints(protocols))
199
+ await server.configure().init()
200
+
201
+ const calls: Call[] = []
202
+ let opened = 0
203
+
204
+ const requestOf = (req: Partial<AbstractRequest>, body?: unknown): AbstractRequest => ({
205
+ alias: req.alias ?? '',
206
+ params: wire(req.params ?? {}),
207
+ query: wire(req.query ?? {}),
208
+ body: body as AbstractRequest['body'],
209
+ headers: {},
210
+ path: req.path ?? '',
211
+ auth: AUTH,
212
+ entity: entityOf(entityId),
213
+ })
214
+
215
+ const client = makeClientContext({
216
+ ready: false, service: 'client-planning-tests', type: AppType.Frontend, layer: undefined, services: {},
217
+ } as unknown as ClientConfig) as ClientCtx
218
+
219
+ client.registerService(createService<EntrypointTransport>(transportAlias(RouteProtocols.WEB), {
220
+ protocol: RouteProtocols.WEB,
221
+ handle: (async (req: AbstractRequest, res: AbstractResponse<unknown>) => {
222
+ for (const [key, value] of Object.entries(req.query ?? {})) {
223
+ if (value != null && typeof value === 'object') {
224
+ throw new SyntaxError(`Query value ${key} does not survive a URL`)
225
+ }
226
+ }
227
+ const request = requestOf(req, wire(req.body))
228
+ const reply = provideResponse<unknown>()
229
+ try {
230
+ await server.entrypoint<BasicEntrypoint & Handled>(req.alias).handle!(request, reply)
231
+ } catch (e) {
232
+ reply.reject(e as Error)
233
+ }
234
+ const call = { alias: req.alias, query: request.query as Record<string, unknown> }
235
+ calls.push(call)
236
+ await opts.after?.(call)
237
+ if (reply.error != null) {
238
+ // What the server writes as the error body, and what the API client makes of it.
239
+ const text = ResilientError.marshal(ResilientError.ensure(reply.error)).message
240
+ res.reject(ResilientError.ensure(text, true))
241
+ return
242
+ }
243
+ res.resolve(wire(reply.value), reply.outcome ?? EntrypointOutcome.Ok)
244
+ }) as EntrypointHandler,
245
+ }))
246
+
247
+ appendPlanningClient(client, {
248
+ protocols,
249
+ poll: opts.poll ?? 2,
250
+ schemas: opts.schemas,
251
+ ...(opts.socket === true
252
+ ? {
253
+ socket: async (protocol, request) => {
254
+ const pair = socketPair()
255
+ await server.entrypoint<BasicEntrypoint & Handled>(protocol.alias).handle!(
256
+ requestOf({ alias: protocol.alias, query: request?.query ?? {} }, pair.server), provideResponse()
257
+ )
258
+ opened += 1
259
+ return pair.client
260
+ },
261
+ }
262
+ : {}),
263
+ })
264
+ if (opts.stores !== false) {
265
+ appendPlanningStores(client)
266
+ }
267
+ await client.configure().init()
268
+
269
+ /** A write the server itself makes, folded whichever way the store folds. */
270
+ const committed = async (pending: Promise<TransitionReceipt>): Promise<Workcard> => {
271
+ const receipt = await pending
272
+ if (opts.sync === false) {
273
+ await store.flush()
274
+ }
275
+ return await receipt.committed() as Workcard
276
+ }
277
+
278
+ const service = (server as unknown as { planning: () => PlanningService }).planning()
279
+ const local = service.for({ entityId, profileId: PROFILE_ID })
280
+ const planning = client.planning().for()
281
+
282
+ return {
283
+ server,
284
+ store,
285
+ local,
286
+ client,
287
+ planning,
288
+ calls,
289
+ sockets: () => opened,
290
+ project: async (title = 'Board') => await committed(local.execute({
291
+ card: { kind: WorkcardKind.Project, type: PROJECT, title },
292
+ action: TransitionAction.Create,
293
+ })) as Project,
294
+ story: async (project, title = 'A story', fields) => await committed(local.execute({
295
+ card: { kind: WorkcardKind.Card, type: STORY, parent: project, title, ...(fields != null ? { fields } : {}) },
296
+ action: TransitionAction.Create,
297
+ })),
298
+ }
299
+ }
300
+
301
+ export const tick = async (ms = 0): Promise<void> => await new Promise(resolve => setTimeout(resolve, ms))
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { CommitState, TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
+ import type { CommitEvent, Transition, Workcard } from '@owlmeans/planning'
4
+ import { applyCards, applyCommitEvent, syncLinks } from '../src/index.js'
5
+ import { STORY, makeSuite } from './context.js'
6
+
7
+ /** The frame a bus publishes for a transition — ids only unless a record is given. */
8
+ const frameOf = (transition: Transition, record?: Workcard | null): CommitEvent => ({
9
+ transition: transition.id!,
10
+ card: transition.card,
11
+ entityId: transition.entityId,
12
+ project: transition.project,
13
+ kind: transition.kind,
14
+ type: transition.type,
15
+ seq: transition.seq,
16
+ action: transition.action,
17
+ state: CommitState.Committed,
18
+ at: transition.at,
19
+ ...(record !== undefined ? { record } : {}),
20
+ })
21
+
22
+ describe('@owlmeans/client-planning — commit folds', () => {
23
+ test('a committed frame without a record writes the card re-read through the facade', async () => {
24
+ const suite = await makeSuite()
25
+ const stores = suite.client.planningStores!()
26
+ const project = await suite.project()
27
+ const receipt = await suite.local.execute({
28
+ card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Folded' },
29
+ action: TransitionAction.Create,
30
+ }, { wait: true })
31
+
32
+ await applyCommitEvent(stores, frameOf(receipt.transition), suite.planning)
33
+
34
+ expect(await stores.cards.get(receipt.transition.card)).toEqual(receipt.card!)
35
+ expect((await stores.commits.get(receipt.transition.id!)).state).toBe(CommitState.Committed)
36
+ })
37
+
38
+ test('a committed delete removes the row and every link touching it', async () => {
39
+ const suite = await makeSuite()
40
+ const stores = suite.client.planningStores!()
41
+ const project = await suite.project()
42
+ const first = await suite.story(project.id!, 'First')
43
+ const second = (await suite.local.execute({
44
+ card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Second' },
45
+ action: TransitionAction.Create,
46
+ links: [{ type: 'follows', to: first.id! }],
47
+ }, { wait: true })).card!
48
+ await applyCards(stores, [first, second])
49
+ const links = await suite.planning.relationships.list({ from: second.id })
50
+ await syncLinks(stores.links, links.items)
51
+ expect(await stores.links.count()).toBe(1)
52
+
53
+ const removal = await suite.local.execute({ card: first.id!, action: TransitionAction.Delete }, { wait: true })
54
+ await applyCommitEvent(stores, frameOf(removal.transition, null), suite.planning)
55
+
56
+ expect(await stores.cards.load(first.id!)).toBeNull()
57
+ expect(await stores.cards.load(second.id!)).not.toBeNull()
58
+ expect(await stores.links.count()).toBe(0)
59
+ })
60
+
61
+ test('a frame for an id the store never saw still writes a row', async () => {
62
+ const suite = await makeSuite()
63
+ const stores = suite.client.planningStores!()
64
+ const project = await suite.project()
65
+ const receipt = await suite.local.execute({
66
+ card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'From another tab' },
67
+ action: TransitionAction.Create,
68
+ }, { wait: true })
69
+ expect(await stores.cards.count()).toBe(0)
70
+
71
+ await applyCommitEvent(stores, frameOf(receipt.transition, receipt.card))
72
+
73
+ expect((await stores.cards.get(receipt.transition.card)).title).toBe('From another tab')
74
+ })
75
+
76
+ test('an older seq never overwrites a newer record', async () => {
77
+ const suite = await makeSuite()
78
+ const stores = suite.client.planningStores!()
79
+ const project = await suite.project()
80
+ const created = await suite.local.execute({
81
+ card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Racing' },
82
+ action: TransitionAction.Create,
83
+ }, { wait: true })
84
+ const started = await suite.local.execute({
85
+ card: created.transition.card, action: TransitionAction.Transit, transition: 'start',
86
+ }, { wait: true })
87
+ await applyCards(stores, [started.card!])
88
+
89
+ await applyCommitEvent(stores, frameOf(created.transition, created.card))
90
+
91
+ const stored = await stores.cards.get(created.transition.card)
92
+ expect(stored.seq).toBe(2)
93
+ expect(stored.status).toBe('doing')
94
+ })
95
+ })
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ IllegalTransition, TransitionAction, WorkcardKind, WorkcardNotFound,
4
+ } from '@owlmeans/planning'
5
+ import type { Specification, WorkcardQuery } from '@owlmeans/planning'
6
+ import { OTHER_ENTITY_ID, PROFILE_ID, PROJECT, STORY, makeSuite } from './context.js'
7
+
8
+ describe('@owlmeans/client-planning — remote facade', () => {
9
+ test('a rich query means on the client exactly what it means on the server', async () => {
10
+ const suite = await makeSuite()
11
+ const project = await suite.project()
12
+ await suite.story(project.id!, 'Alpha login', { area: 'auth' })
13
+ await suite.story(project.id!, 'Beta login', { area: 'billing' })
14
+ await suite.story(project.id!, 'Gamma search', { area: 'auth' })
15
+
16
+ const query: WorkcardQuery = {
17
+ kind: WorkcardKind.Card, type: [STORY], parent: project.id, status: ['todo', 'doing'],
18
+ fields: { area: 'auth' }, q: 'login', sort: ['title'], size: 0,
19
+ }
20
+ const remote = await suite.planning.cards.list(query)
21
+
22
+ expect(remote).toEqual(await suite.local.cards.list(query))
23
+ expect(remote.items.map(card => card.title)).toEqual(['Alpha login'])
24
+ expect(await suite.planning.cards.count({ parent: project.id })).toBe(3)
25
+ expect(await suite.planning.cards.summary([project.id!])).toEqual(await suite.local.cards.summary([project.id!]))
26
+ })
27
+
28
+ test('a server refusal arrives as its own class', async () => {
29
+ const suite = await makeSuite()
30
+ const project = await suite.project()
31
+ const story = await suite.story(project.id!)
32
+
33
+ const illegal = suite.planning.execute({ card: story.id!, action: TransitionAction.Transit, transition: 'finish' })
34
+ await expect(illegal).rejects.toBeInstanceOf(IllegalTransition)
35
+
36
+ await expect(suite.planning.cards.get('missing')).rejects.toBeInstanceOf(WorkcardNotFound)
37
+ expect(await suite.planning.cards.load('missing')).toBeNull()
38
+
39
+ // Another entity's card is answered exactly like a missing one.
40
+ const foreign = await (suite.server as any).planning().for({ entityId: OTHER_ENTITY_ID }).execute({
41
+ card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Theirs' }, action: TransitionAction.Create,
42
+ }, { wait: true })
43
+ await expect(suite.planning.cards.get(foreign.card.id)).rejects.toBeInstanceOf(WorkcardNotFound)
44
+ })
45
+
46
+ test('execute answers a receipt whose committed() resolves with the folded card', async () => {
47
+ const suite = await makeSuite({ sync: false })
48
+ const project = await suite.project()
49
+
50
+ const receipt = await suite.planning.execute({
51
+ card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Remote story' },
52
+ action: TransitionAction.Create,
53
+ // The server decides who wrote; whatever the wire says is ignored.
54
+ actor: { profileId: 'forged' },
55
+ })
56
+ expect(receipt.transition.actor.profileId).toBe(PROFILE_ID)
57
+
58
+ setTimeout(() => { void suite.store.flush() }, 50)
59
+ const card = await receipt.committed({ timeout: 5_000 })
60
+ expect(card?.title).toBe('Remote story')
61
+ expect(card?.code).toMatch(/^ST-/)
62
+
63
+ const model = await suite.planning.model(card!)
64
+ expect(model.can('start')).toBe(true)
65
+ setTimeout(() => { void suite.store.flush() }, 50)
66
+ const started = await model.transit('start', undefined, { wait: true, timeout: 5_000 })
67
+ expect(started.card?.status).toBe('doing')
68
+ })
69
+
70
+ test('a model writes a specification, then revises the same record', async () => {
71
+ const suite = await makeSuite()
72
+ const project = await suite.planning.model(await suite.project())
73
+
74
+ await project.write('brief', '# First', { wait: true })
75
+ await project.write('brief', '# Second', { wait: true })
76
+
77
+ const current = await suite.planning.specifications.current(project.id, 'brief') as Specification
78
+ expect(current.body).toBe('# Second')
79
+ expect(current.revision).toBe(2)
80
+ const history = await suite.planning.specifications.revisions(current.id!)
81
+ expect(history.map(revision => revision.body)).toContain('# First')
82
+ })
83
+ })
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { makePlanningFeed } from '../src/index.js'
3
+ import { makeSuite, tick } from './context.js'
4
+
5
+ describe('@owlmeans/client-planning — the feed', () => {
6
+ test('seeds the mirror from the list, then folds every commit the socket pushes', async () => {
7
+ const suite = await makeSuite({ socket: true })
8
+ const stores = suite.client.planningStores!()
9
+ const project = await suite.project()
10
+ const first = await suite.story(project.id!, 'Seeded')
11
+
12
+ const feed = makePlanningFeed(suite.client, { query: { parent: project.id }, filter: { project: project.id } })
13
+ await feed.ready
14
+ expect(feed.seeded).toBe(true)
15
+ expect(feed.connected).toBe(true)
16
+ expect((await stores.cards.get(first.id!)).title).toBe('Seeded')
17
+
18
+ const second = await suite.story(project.id!, 'Pushed')
19
+ await tick(20)
20
+ expect((await stores.cards.get(second.id!)).title).toBe('Pushed')
21
+
22
+ await feed.stop()
23
+ })
24
+
25
+ test('stop() ends the folding and leaves the shared socket open', async () => {
26
+ const suite = await makeSuite({ socket: true })
27
+ const stores = suite.client.planningStores!()
28
+ const project = await suite.project()
29
+
30
+ const feed = makePlanningFeed(suite.client, { query: { parent: project.id } })
31
+ await feed.ready
32
+ await feed.stop()
33
+
34
+ const late = await suite.story(project.id!, 'After stop')
35
+ await tick(20)
36
+ expect(await stores.cards.load(late.id!)).toBeNull()
37
+ expect(suite.client.planning().commits.connected()).toBe(true)
38
+ })
39
+ })
@@ -0,0 +1,67 @@
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'
4
+ import { makeSuite } from './context.js'
5
+
6
+ describe('@owlmeans/client-planning — the state mirror', () => {
7
+ test('syncCards drops only what matches `where` and the list does not name', async () => {
8
+ const suite = await makeSuite()
9
+ const stores = suite.client.planningStores!()
10
+ const one = await suite.project('One')
11
+ const two = await suite.project('Two')
12
+ const kept = await suite.story(one.id!, 'Kept')
13
+ const deleted = await suite.story(one.id!, 'Deleted elsewhere')
14
+ const elsewhere = await suite.story(two.id!, 'Another project')
15
+ await applyCards(stores, [one, two, kept, deleted, elsewhere])
16
+
17
+ await syncCards(stores.cards, [kept], { parent: one.id })
18
+
19
+ const ids = (await stores.cards.list()).items.map(card => card.id).sort()
20
+ expect(ids).toEqual([one.id, two.id, kept.id, elsewhere.id].sort())
21
+ })
22
+
23
+ test('projects, cards and specifications share one store and one id space', async () => {
24
+ const suite = await makeSuite()
25
+ const stores = suite.client.planningStores!()
26
+ const project = await suite.project()
27
+ await (await suite.planning.model(project)).write('brief', '# Brief', { wait: true })
28
+ await suite.story(project.id!)
29
+
30
+ const [projects, specs, cards] = await Promise.all([
31
+ suite.planning.cards.list({ kind: WorkcardKind.Project }),
32
+ suite.planning.specifications.list(project.id!),
33
+ suite.planning.cards.list({ kind: WorkcardKind.Card, parent: project.id }),
34
+ ])
35
+ await syncCards(stores.cards, projects.items, { kind: WorkcardKind.Project })
36
+ await syncCards(stores.cards, specs.items, { kind: WorkcardKind.Specification, parent: project.id })
37
+ // 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 })
40
+
41
+ const kinds = (await stores.cards.list()).items.map(card => card.kind).sort()
42
+ expect(kinds).toEqual([WorkcardKind.Card, WorkcardKind.Project, WorkcardKind.Specification])
43
+ })
44
+
45
+ test('a receipt marks the card pending (head > seq) and its commit clears the mark', async () => {
46
+ const suite = await makeSuite({ sync: false })
47
+ const stores = suite.client.planningStores!()
48
+ const project = await suite.project()
49
+ const story = await suite.story(project.id!)
50
+ await applyCards(stores, [story])
51
+
52
+ const receipt = await suite.planning.execute({
53
+ card: story.id!, action: TransitionAction.Transit, transition: 'start', expectSeq: story.seq,
54
+ })
55
+
56
+ const marked = await stores.cards.get(story.id!)
57
+ expect(isPending(marked)).toBe(true)
58
+ expect(modelOf(marked, suite.planning).pending()).toBe(true)
59
+
60
+ await suite.store.flush()
61
+ await receipt.committed({ timeout: 5_000 })
62
+
63
+ const folded = await stores.cards.get(story.id!)
64
+ expect(isPending(folded)).toBe(false)
65
+ expect(folded.status).toBe('doing')
66
+ })
67
+ })
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": [
8
+ "bun"
9
+ ],
10
+ "rootDir": "../",
11
+ "noEmit": true
12
+ },
13
+ "include": [
14
+ "./**/*",
15
+ "../src/**/*"
16
+ ]
17
+ }