@owlmeans/client-planning 0.1.18-rc.8 → 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
package/tests/context.ts CHANGED
@@ -9,7 +9,7 @@ import type {
9
9
  } from '@owlmeans/entrypoint'
10
10
  import { ResilientError } from '@owlmeans/error'
11
11
  import {
12
- CodeScope, CodeStyle, IntrinsicStatus, makePlanningProtocols, SpecificationFormat, TransitionAction,
12
+ AssigneeKind, CodeScope, CodeStyle, IntrinsicStatus, makePlanningProtocols, SpecificationFormat, TransitionAction,
13
13
  WorkcardKind,
14
14
  } from '@owlmeans/planning'
15
15
  import type {
@@ -86,6 +86,7 @@ const types: AnyTypeSchema[] = [
86
86
  flows: [PROJECT_FLOW],
87
87
  specifications: [{ category: 'brief', format: SpecificationFormat.Markdown, revisioned: true, type: SPEC }],
88
88
  cardTypes: [STORY],
89
+ scopedCardTypes: true,
89
90
  },
90
91
  {
91
92
  type: STORY,
@@ -111,13 +112,23 @@ const types: AnyTypeSchema[] = [
111
112
  },
112
113
  ]
113
114
 
114
- export const SCHEMAS: PlanningSchemaBundle = { version: 1, types, flows }
115
+ const assigneeTypes = [{ type: 'test:participant', version: 1, kind: AssigneeKind.Human, fields: { type: 'object', additionalProperties: false } }]
116
+ export const SCHEMAS: PlanningSchemaBundle = { version: 1, types, flows, assigneeTypes }
115
117
 
116
118
  export const protocols = makePlanningProtocols({
117
119
  base: { alias: 'test:planning', path: '/planning' },
118
120
  guards: [],
119
121
  })
120
122
 
123
+ /** The same mount with the data-defined schema surface. */
124
+ export const definitionProtocols = makePlanningProtocols({
125
+ base: { alias: 'test:planning', path: '/planning' },
126
+ guards: [],
127
+ definitions: true,
128
+ })
129
+
130
+ export const resourceProtocols = makePlanningProtocols({ base: { alias: 'test:planning', path: '/planning' }, guards: [], definitions: true, resources: true })
131
+
121
132
  const AUTH = { profileId: PROFILE_ID, userId: 'user-1', entityId: 'acme' } as unknown as Auth
122
133
  const entityOf = (id: string): ResolvedEntity => ({ id, slug: id, iamKey: id })
123
134
 
@@ -125,19 +136,28 @@ const entityOf = (id: string): ResolvedEntity => ({ id, slug: id, iamKey: id })
125
136
  export interface Call {
126
137
  alias: string
127
138
  query: Record<string, unknown>
139
+ reply?: unknown
140
+ signal?: AbortSignal
128
141
  }
129
142
 
130
- export interface SuiteOptions extends Partial<Pick<PlanningClientOptions, 'poll' | 'schemas'>> {
143
+ export interface SuiteOptions extends Partial<Pick<PlanningClientOptions, 'poll' | 'schemas' | 'scopeKey'>> {
131
144
  /** The server folds inside `execute` (default) or only on `store.flush()`. */
145
+ resources?: boolean
132
146
  sync?: boolean
133
147
  /** Give the client a commit socket. */
134
148
  socket?: boolean
135
149
  /** Register the state mirror on the client. */
136
150
  stores?: boolean
137
151
  /** The entity the transport authenticates as. */
138
- entityId?: string
152
+ entityId?: string | (() => string)
153
+ /** Mount the tree with `definitions: true` over a store holding data-defined schemas. */
154
+ definitions?: boolean
139
155
  /** Runs after the server answered a call and before the client sees the answer. */
140
156
  after?: (call: Call) => Promise<void>
157
+ /** A barrier at the actual transport boundary, before the real server handler runs. */
158
+ before?: (request: AbstractRequest) => Promise<void>
159
+ /** A barrier after the real socket handler connected but before its opener returns. */
160
+ opening?: (connection: Connection, attempt: number) => Promise<void>
141
161
  }
142
162
 
143
163
  export type ClientCtx = ClientContext<ClientConfig> & WithPlanningClient & Partial<WithPlanningStores>
@@ -151,6 +171,7 @@ export interface Suite {
151
171
  /** The client's facade. */
152
172
  planning: PlanningFacade
153
173
  calls: Call[]
174
+ frames: unknown[]
154
175
  sockets: () => number
155
176
  project: (title?: string) => Promise<Project>
156
177
  story: (project: string, title?: string, fields?: Record<string, unknown>) => Promise<Workcard>
@@ -161,7 +182,7 @@ const wire = <T>(value: T): T => value === undefined ? value : JSON.parse(JSON.s
161
182
  type Handled = { handle?: EntrypointHandler }
162
183
 
163
184
  /** A WebSocket-shaped pipe for the server handler, wired to a client connection reading its frames. */
164
- const socketPair = (): { server: unknown, client: Connection } => {
185
+ const socketPair = (frames: unknown[]): { server: unknown, client: Connection } => {
165
186
  const handlers = new Map<string, Set<(...args: unknown[]) => unknown>>()
166
187
  const emit = async (event: string, ...args: unknown[]): Promise<void> => {
167
188
  for (const handler of [...(handlers.get(event) ?? [])]) {
@@ -170,7 +191,7 @@ const socketPair = (): { server: unknown, client: Connection } => {
170
191
  }
171
192
  const client = createBasicConnection()
172
193
  const server = {
173
- send: async (data: string) => { await client.receive(data) },
194
+ send: async (data: string) => { frames.push(JSON.parse(data)); await client.receive(data) },
174
195
  close: async () => { await emit('close', 1000) },
175
196
  on: (event: string, handler: (...args: unknown[]) => unknown) => {
176
197
  handlers.set(event, (handlers.get(event) ?? new Set()).add(handler))
@@ -188,17 +209,23 @@ const socketPair = (): { server: unknown, client: Connection } => {
188
209
  }
189
210
 
190
211
  export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
191
- const entityId = opts.entityId ?? ENTITY_ID
212
+ const entityKey = () => typeof opts.entityId === 'function' ? opts.entityId() : opts.entityId ?? ENTITY_ID
213
+ const entityId = entityKey()
192
214
 
193
215
  const server = makeBasicContext<BasicConfig>({
194
216
  ready: false, service: 'client-planning-tests-server', type: AppType.Backend,
195
217
  } as BasicConfig)
196
- const store = makeMemoryPlanningStore({ sync: opts.sync !== false })
197
- appendPlanningService(server, { store, schemas: { types, flows } })
198
- server.registerEntrypoints(servePlanningEntrypoints(protocols))
218
+ const tree = opts.resources === true ? resourceProtocols : opts.definitions === true ? definitionProtocols : protocols
219
+ const store = makeMemoryPlanningStore({ sync: opts.sync !== false, schemas: opts.definitions === true || opts.resources === true })
220
+ appendPlanningService(server, { store, schemas: { types, flows, assigneeTypes } })
221
+ let author: string | undefined
222
+ server.registerEntrypoints(servePlanningEntrypoints(tree, { scope: () => ({ assigneeId: author }) }))
199
223
  await server.configure().init()
200
224
 
225
+ if (opts.resources) author = (await (server as unknown as { planning: () => PlanningService }).planning().for({ entityId }).assignees.create({ nickname: 'commenter', type: 'test:participant', kind: AssigneeKind.Human, authentication: { provider: 'tests', externalId: PROFILE_ID } })).id
226
+
201
227
  const calls: Call[] = []
228
+ const frames: unknown[] = []
202
229
  let opened = 0
203
230
 
204
231
  const requestOf = (req: Partial<AbstractRequest>, body?: unknown): AbstractRequest => ({
@@ -209,7 +236,7 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
209
236
  headers: {},
210
237
  path: req.path ?? '',
211
238
  auth: AUTH,
212
- entity: entityOf(entityId),
239
+ entity: entityOf(entityKey()),
213
240
  })
214
241
 
215
242
  const client = makeClientContext({
@@ -225,13 +252,14 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
225
252
  }
226
253
  }
227
254
  const request = requestOf(req, wire(req.body))
255
+ await opts.before?.(req)
228
256
  const reply = provideResponse<unknown>()
229
257
  try {
230
258
  await server.entrypoint<BasicEntrypoint & Handled>(req.alias).handle!(request, reply)
231
259
  } catch (e) {
232
260
  reply.reject(e as Error)
233
261
  }
234
- const call = { alias: req.alias, query: request.query as Record<string, unknown> }
262
+ const call = { alias: req.alias, query: request.query as Record<string, unknown>, reply: wire(reply.value), signal: req.signal }
235
263
  calls.push(call)
236
264
  await opts.after?.(call)
237
265
  if (reply.error != null) {
@@ -245,17 +273,19 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
245
273
  }))
246
274
 
247
275
  appendPlanningClient(client, {
248
- protocols,
276
+ protocols: tree,
277
+ scopeKey: opts.scopeKey,
249
278
  poll: opts.poll ?? 2,
250
279
  schemas: opts.schemas,
251
280
  ...(opts.socket === true
252
281
  ? {
253
282
  socket: async (protocol, request) => {
254
- const pair = socketPair()
283
+ const pair = socketPair(frames)
255
284
  await server.entrypoint<BasicEntrypoint & Handled>(protocol.alias).handle!(
256
285
  requestOf({ alias: protocol.alias, query: request?.query ?? {} }, pair.server), provideResponse()
257
286
  )
258
287
  opened += 1
288
+ await opts.opening?.(pair.client, opened)
259
289
  return pair.client
260
290
  },
261
291
  }
@@ -276,7 +306,7 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
276
306
  }
277
307
 
278
308
  const service = (server as unknown as { planning: () => PlanningService }).planning()
279
- const local = service.for({ entityId, profileId: PROFILE_ID })
309
+ const local = service.for({ entityId, profileId: PROFILE_ID, assigneeId: author })
280
310
  const planning = client.planning().for()
281
311
 
282
312
  return {
@@ -286,6 +316,7 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
286
316
  client,
287
317
  planning,
288
318
  calls,
319
+ frames,
289
320
  sockets: () => opened,
290
321
  project: async (title = 'Board') => await committed(local.execute({
291
322
  card: { kind: WorkcardKind.Project, type: PROJECT, title },
@@ -0,0 +1,119 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { appSchemaHelper, makeSchemaRegistry } from '@owlmeans/planning'
3
+ import type { PlanningAppDeclarations, PlanningService } from '@owlmeans/planning'
4
+ import { makePlanningFieldValidator } from '../src/index.js'
5
+ import { makeSuite, STORY } from './context.js'
6
+ import type { Suite, SuiteOptions } from './context.js'
7
+
8
+ /** What a browser says when a page's `script-src` lacks `'unsafe-eval'`. */
9
+ const REFUSAL = "Evaluating a string as JavaScript violates the following Content Security Policy directive because 'unsafe-eval' is not an allowed source of script"
10
+
11
+ /** The work-management seed's declarations: every type and flow may carry a description. */
12
+ const APP: PlanningAppDeclarations = {
13
+ type: { properties: { description: { type: 'string', maxLength: 2000 } } },
14
+ flow: { properties: { description: { type: 'string', maxLength: 2000 } } },
15
+ }
16
+ const SERVED = appSchemaHelper.merge([APP])
17
+
18
+ /**
19
+ * The CLIENT runs in a browser page under the publisher's policy — a `document`, and every code
20
+ * generation refused — while the in-process server it calls does not: the suite's transport leaves
21
+ * the page before the server handler runs (`before`) and re-enters it once the server answered
22
+ * (`after`). Armed only by a test, so the suite's own set-up runs outside it.
23
+ */
24
+ const makeStrictPage = () => {
25
+ const scope = globalThis as unknown as Record<string, unknown>
26
+ const saved = { Function: scope.Function as FunctionConstructor, eval: scope.eval as typeof eval, error: console.error }
27
+ const counts = { generated: 0, logged: 0 }
28
+ const refuse = (): never => {
29
+ counts.generated++
30
+ throw new EvalError(REFUSAL)
31
+ }
32
+ const refusing = { Function: new Proxy(saved.Function, { construct: refuse, apply: refuse }), eval: new Proxy(saved.eval, { apply: refuse }) }
33
+ let armed = false
34
+
35
+ const enter = (): void => {
36
+ scope.document = {}
37
+ scope.Function = refusing.Function
38
+ scope.eval = refusing.eval
39
+ console.error = () => { counts.logged++ }
40
+ }
41
+ const leave = (): void => {
42
+ delete scope.document
43
+ scope.Function = saved.Function
44
+ scope.eval = saved.eval
45
+ console.error = saved.error
46
+ }
47
+
48
+ return {
49
+ counts,
50
+ arm: () => { armed = true; counts.generated = 0; counts.logged = 0; enter() },
51
+ disarm: () => { armed = false; leave() },
52
+ hooks: {
53
+ before: async () => { if (armed) leave() },
54
+ after: async () => { if (armed) enter() },
55
+ } satisfies Pick<SuiteOptions, 'before' | 'after'>,
56
+ }
57
+ }
58
+
59
+ const page = makeStrictPage()
60
+
61
+ /** A seed-like target: the application's declarations registered on the server, as its plugin does. */
62
+ const declared = async (opts: SuiteOptions = {}): Promise<Suite> => {
63
+ const suite = await makeSuite({ schemas: false, ...opts, ...page.hooks })
64
+ ;(suite.server as unknown as { planning: () => PlanningService }).planning().use({ name: 'seed', schemas: { app: APP } })
65
+
66
+ return suite
67
+ }
68
+
69
+ afterEach(() => page.disarm())
70
+
71
+ describe('@owlmeans/client-planning — a page under a strict script-src', () => {
72
+ test('the served bundle loads, a model resolves and a form validates without generating code', async () => {
73
+ const suite = await declared()
74
+ const project = await suite.project('Spring')
75
+ const story = await suite.story(project.id!, 'Warm-up', { area: 'stage' })
76
+ const service = suite.client.planning()
77
+
78
+ page.arm()
79
+ const registry = await service.loadSchemas()
80
+ expect(registry.app()).toEqual(SERVED!)
81
+ const model = await suite.planning.model(story.id!)
82
+ expect(model.available().map(rule => rule.name)).toContain('start')
83
+ const form = makePlanningFieldValidator(registry.fieldsSchema(STORY))
84
+ expect(form.validateFields({ area: 'stage' }).valid).toBe(true)
85
+ expect(form.validateFields({ area: 2 }).valid).toBe(false)
86
+ page.disarm()
87
+
88
+ expect(page.counts).toEqual({ generated: 0, logged: 0 })
89
+ })
90
+
91
+ test('an organization layer and a project layer resolve through definitions without generating code', async () => {
92
+ const suite = await declared({ definitions: true })
93
+ const project = await suite.project('Spring')
94
+ const story = await suite.story(project.id!, 'Warm-up', { area: 'stage' })
95
+ const definitions = suite.planning.definitions!
96
+
97
+ page.arm()
98
+ const layer = await definitions.registry()
99
+ expect(layer.app()).toEqual(SERVED!)
100
+ expect((await definitions.bundle()).app).toEqual(SERVED!)
101
+ expect((await definitions.registry(project.id!)).app()).toEqual(SERVED!)
102
+ const model = await suite.planning.model(story.id!)
103
+ expect(model.schema().type).toBe(STORY)
104
+ page.disarm()
105
+
106
+ expect(page.counts).toEqual({ generated: 0, logged: 0 })
107
+ })
108
+
109
+ test('the page really refuses code generation: a native validator cannot be compiled in it', async () => {
110
+ const suite = await declared()
111
+ const bundle = (await suite.client.planning().loadSchemas()).bundle()
112
+
113
+ page.arm()
114
+ expect(() => makeSchemaRegistry(bundle).validator(STORY)).toThrow(EvalError)
115
+ page.disarm()
116
+
117
+ expect(page.counts.generated).toBeGreaterThan(0)
118
+ })
119
+ })
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ PlanningSchemaKind, PlanningUnsupported, SchemaOrigin, TransitionAction, WorkcardKind,
4
+ } from '@owlmeans/planning'
5
+ import type { WorkcardTypeSchema } from '@owlmeans/planning'
6
+ import { definitionProtocols, makeSuite, STORY_FLOW } from './context.js'
7
+
8
+ /** A choir's rehearsal card, defined as data for one project. */
9
+ const REHEARSAL: WorkcardTypeSchema = {
10
+ type: 'test:rehearsal', kind: WorkcardKind.Card, version: 1, fields: { type: 'object' }, flows: [STORY_FLOW], specifications: [],
11
+ }
12
+
13
+ describe('@owlmeans/client-planning — data-defined schemas', () => {
14
+ test('a layer is read once per project and read again after any write', async () => {
15
+ const suite = await makeSuite({ definitions: true })
16
+ const project = await suite.project('Spring concert')
17
+ const definitions = suite.planning.definitions!
18
+ const reads = () => suite.calls.filter(call => call.alias === definitionProtocols.schema.list.alias).length
19
+
20
+ const written = await definitions.define({ types: [REHEARSAL] }, { project: project.id })
21
+ expect(written.map(record => [record.kind, record.key, record.version, record.project]))
22
+ .toEqual([[PlanningSchemaKind.Type, REHEARSAL.type, 1, project.id]])
23
+
24
+ const before = reads()
25
+ const layer = await definitions.registry(project.id)
26
+ await definitions.registry(project.id)
27
+ expect(reads()).toBe(before + 1)
28
+ expect(layer.originOf(PlanningSchemaKind.Type, REHEARSAL.type)).toBe(SchemaOrigin.Project)
29
+ expect((await definitions.bundle()).types.some(type => type.type === REHEARSAL.type)).toBe(false)
30
+
31
+ await definitions.putType({ ...REHEARSAL, version: 2, label: 'Rehearsal' }, { project: project.id })
32
+ expect((await definitions.registry(project.id)).type(REHEARSAL.type).label).toBe('Rehearsal')
33
+ expect(reads()).toBe(before + 3)
34
+ await expect(definitions.records()).rejects.toBeInstanceOf(PlanningUnsupported)
35
+ })
36
+
37
+ test('a model of a card resolves its type in its own project\'s layer', async () => {
38
+ const suite = await makeSuite({ definitions: true })
39
+ const project = await suite.project('Autumn concert')
40
+ await suite.planning.definitions!.define({ types: [REHEARSAL] }, { project: project.id })
41
+ const card = (await suite.local.execute({
42
+ card: { kind: WorkcardKind.Card, type: REHEARSAL.type, parent: project.id!, title: 'Sectionals' },
43
+ action: TransitionAction.Create,
44
+ }, { wait: true })).card!
45
+
46
+ const model = await suite.planning.model(card.id!)
47
+ expect(model.schema().type).toBe(REHEARSAL.type)
48
+ expect(model.available().map(rule => rule.name)).toEqual(['start', 'reset'])
49
+ })
50
+
51
+ test('a nested card model resolves its data-defined schema through the primary ancestor chain', async () => {
52
+ const suite = await makeSuite({ definitions: true })
53
+ const project = await suite.project('Nested rehearsals')
54
+ await suite.planning.definitions!.define({ types: [{ ...REHEARSAL, children: { types: [REHEARSAL.type] } }] }, { project: project.id })
55
+ const create = async (parent: string) => (await suite.local.execute({ action: TransitionAction.Create,
56
+ card: { kind: WorkcardKind.Card, type: REHEARSAL.type, title: 'Section', parent },
57
+ }, { wait: true })).card!
58
+ const parent = await create(project.id!)
59
+ const child = await create(parent.id!)
60
+ const grandchild = await create(child.id!)
61
+ const model = await suite.planning.model(grandchild.id!)
62
+ expect(model.schema().type).toBe(REHEARSAL.type)
63
+ expect(model.available().map(rule => rule.name)).toEqual(['start', 'reset'])
64
+ const layers = suite.calls.filter(call => call.alias === definitionProtocols.schema.list.alias).map(call => call.query?.project)
65
+ expect(layers).not.toContain(parent.id)
66
+ expect(layers).not.toContain(child.id)
67
+ })
68
+
69
+ test('a tree declared without definitions has none', async () => {
70
+ const suite = await makeSuite()
71
+ expect(suite.planning.definitions).toBeUndefined()
72
+ expect(suite.client.planning().definitions).toBeUndefined()
73
+ })
74
+ })
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { JSON_SCHEMA_DRAFT_07 } from '../src/validation/consts.js'
5
+
6
+ describe('@owlmeans/client-planning — the draft-07 meta-schema', () => {
7
+ test('is ajv\'s own refs file, kept as data rather than imported as JSON', () => {
8
+ const file = createRequire(import.meta.url).resolve('ajv/dist/refs/json-schema-draft-07.json')
9
+
10
+ expect(JSON_SCHEMA_DRAFT_07).toEqual(JSON.parse(readFileSync(file, 'utf8')))
11
+ expect(readFileSync(new URL('../src/validation.ts', import.meta.url), 'utf8')).not.toContain('.json\'')
12
+ })
13
+ })
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import { CommitState, TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
3
  import type { CommitEvent, Transition, Workcard } from '@owlmeans/planning'
4
- import { applyCards, applyCommitEvent, syncLinks } from '../src/index.js'
4
+ import { planningMirrorOf, syncHelper } from '../src/index.js'
5
5
  import { STORY, makeSuite } from './context.js'
6
6
 
7
7
  /** The frame a bus publishes for a transition — ids only unless a record is given. */
@@ -23,21 +23,23 @@ describe('@owlmeans/client-planning — commit folds', () => {
23
23
  test('a committed frame without a record writes the card re-read through the facade', async () => {
24
24
  const suite = await makeSuite()
25
25
  const stores = suite.client.planningStores!()
26
+ const mirror = planningMirrorOf(stores)
26
27
  const project = await suite.project()
27
28
  const receipt = await suite.local.execute({
28
29
  card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Folded' },
29
30
  action: TransitionAction.Create,
30
31
  }, { wait: true })
31
32
 
32
- await applyCommitEvent(stores, frameOf(receipt.transition), suite.planning)
33
+ await mirror.applyCommitEvent(frameOf(receipt.transition), suite.planning)
33
34
 
34
- expect(await stores.cards.get(receipt.transition.card)).toEqual(receipt.card!)
35
+ expect(await stores.cards.get(receipt.transition.card)).toEqual({ ...receipt.card!, entityId: '' })
35
36
  expect((await stores.commits.get(receipt.transition.id!)).state).toBe(CommitState.Committed)
36
37
  })
37
38
 
38
39
  test('a committed delete removes the row and every link touching it', async () => {
39
40
  const suite = await makeSuite()
40
41
  const stores = suite.client.planningStores!()
42
+ const mirror = planningMirrorOf(stores)
41
43
  const project = await suite.project()
42
44
  const first = await suite.story(project.id!, 'First')
43
45
  const second = (await suite.local.execute({
@@ -45,13 +47,13 @@ describe('@owlmeans/client-planning — commit folds', () => {
45
47
  action: TransitionAction.Create,
46
48
  links: [{ type: 'follows', to: first.id! }],
47
49
  }, { wait: true })).card!
48
- await applyCards(stores, [first, second])
50
+ await mirror.applyCards([first, second])
49
51
  const links = await suite.planning.relationships.list({ from: second.id })
50
- await syncLinks(stores.links, links.items)
52
+ await syncHelper.syncLinks(stores.links, links.items)
51
53
  expect(await stores.links.count()).toBe(1)
52
54
 
53
55
  const removal = await suite.local.execute({ card: first.id!, action: TransitionAction.Delete }, { wait: true })
54
- await applyCommitEvent(stores, frameOf(removal.transition, null), suite.planning)
56
+ await mirror.applyCommitEvent(frameOf(removal.transition, null), suite.planning)
55
57
 
56
58
  expect(await stores.cards.load(first.id!)).toBeNull()
57
59
  expect(await stores.cards.load(second.id!)).not.toBeNull()
@@ -61,6 +63,7 @@ describe('@owlmeans/client-planning — commit folds', () => {
61
63
  test('a frame for an id the store never saw still writes a row', async () => {
62
64
  const suite = await makeSuite()
63
65
  const stores = suite.client.planningStores!()
66
+ const mirror = planningMirrorOf(stores)
64
67
  const project = await suite.project()
65
68
  const receipt = await suite.local.execute({
66
69
  card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'From another tab' },
@@ -68,7 +71,7 @@ describe('@owlmeans/client-planning — commit folds', () => {
68
71
  }, { wait: true })
69
72
  expect(await stores.cards.count()).toBe(0)
70
73
 
71
- await applyCommitEvent(stores, frameOf(receipt.transition, receipt.card))
74
+ await mirror.applyCommitEvent(frameOf(receipt.transition, receipt.card))
72
75
 
73
76
  expect((await stores.cards.get(receipt.transition.card)).title).toBe('From another tab')
74
77
  })
@@ -76,6 +79,7 @@ describe('@owlmeans/client-planning — commit folds', () => {
76
79
  test('an older seq never overwrites a newer record', async () => {
77
80
  const suite = await makeSuite()
78
81
  const stores = suite.client.planningStores!()
82
+ const mirror = planningMirrorOf(stores)
79
83
  const project = await suite.project()
80
84
  const created = await suite.local.execute({
81
85
  card: { kind: WorkcardKind.Card, type: STORY, parent: project.id, title: 'Racing' },
@@ -84,9 +88,9 @@ describe('@owlmeans/client-planning — commit folds', () => {
84
88
  const started = await suite.local.execute({
85
89
  card: created.transition.card, action: TransitionAction.Transit, transition: 'start',
86
90
  }, { wait: true })
87
- await applyCards(stores, [started.card!])
91
+ await mirror.applyCards([started.card!])
88
92
 
89
- await applyCommitEvent(stores, frameOf(created.transition, created.card))
93
+ await mirror.applyCommitEvent(frameOf(created.transition, created.card))
90
94
 
91
95
  const stored = await stores.cards.get(created.transition.card)
92
96
  expect(stored.seq).toBe(2)
@@ -19,7 +19,8 @@ describe('@owlmeans/client-planning — remote facade', () => {
19
19
  }
20
20
  const remote = await suite.planning.cards.list(query)
21
21
 
22
- expect(remote).toEqual(await suite.local.cards.list(query))
22
+ const native = await suite.local.cards.list(query)
23
+ expect(remote).toEqual({ ...native, items: native.items.map(card => ({ ...card, entityId: '' })) })
23
24
  expect(remote.items.map(card => card.title)).toEqual(['Alpha login'])
24
25
  expect(await suite.planning.cards.count({ parent: project.id })).toBe(3)
25
26
  expect(await suite.planning.cards.summary([project.id!])).toEqual(await suite.local.cards.summary([project.id!]))
@@ -0,0 +1,29 @@
1
+ import type { ResourceRecord } from '@owlmeans/resource'
2
+ import type { StateResource } from '@owlmeans/state'
3
+
4
+ export interface Barrier {
5
+ entered: Promise<void>
6
+ hold: () => Promise<void>
7
+ release: () => void
8
+ }
9
+ export interface StoreBarrier extends Barrier { restore: () => void }
10
+
11
+ /** Delay a real operation once; later calls continue through the actual transport/store. */
12
+ export const makeBarrier = (): Barrier => {
13
+ let entered!: () => void
14
+ let release!: () => void
15
+ let used = false
16
+ const pending = new Promise<void>(resolve => { release = resolve })
17
+ const arrived = new Promise<void>(resolve => { entered = resolve })
18
+ return { entered: arrived, release, hold: async () => {
19
+ if (used) return
20
+ used = true; entered(); await pending
21
+ } }
22
+ }
23
+
24
+ export const pauseStoreRead = <R extends ResourceRecord>(store: StateResource<R>): StoreBarrier => {
25
+ const barrier = makeBarrier()
26
+ const load = store.load.bind(store)
27
+ store.load = async (...args) => { const row: R | null = await Reflect.apply(load, store, args); await barrier.hold(); return row }
28
+ return { ...barrier, restore: () => { store.load = load } }
29
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
+ import type { TransitionReceipt } from '@owlmeans/planning'
4
+ import { makeSuite, STORY, tick } from './context.js'
5
+ import { makeBarrier, pauseStoreRead } from './lifecycle/context.js'
6
+
7
+ describe('@owlmeans/client-planning — draining an authenticated close', () => {
8
+ test('drains admitted receipt and auxiliary folds, rejects mid-close RPCs and leaves no late rows', async () => {
9
+ for (const kind of ['card', 'team', 'commit'] as const) {
10
+ const suite = await makeSuite({ schemas: false, resources: true, sync: kind !== 'commit' })
11
+ const stores = suite.client.planningStores!()
12
+ const project = await suite.project()
13
+ let pending: TransitionReceipt | undefined
14
+ if (kind === 'commit') {
15
+ pending = await suite.planning.execute({ action: TransitionAction.Create, card: { kind: WorkcardKind.Card, type: STORY, parent: project.id!, title: 'Old commit' } })
16
+ await suite.store.flush()
17
+ }
18
+ const pause = kind === 'team' ? pauseStoreRead(stores.teams) : pauseStoreRead(stores.cards)
19
+ const writing = kind === 'commit' ? pending!.committed() : kind === 'card'
20
+ ? suite.planning.execute({ action: TransitionAction.Create, card: { kind: WorkcardKind.Card, type: STORY, parent: project.id!, title: 'Old card' } })
21
+ : suite.planning.teams.create({ name: 'Old team', fields: {} })
22
+ void writing.catch(() => {})
23
+ await pause.entered
24
+ let done = false
25
+ const closing = suite.client.planning().close().then(() => { done = true })
26
+ const again = suite.client.planning().close()
27
+ const calls = suite.calls.length
28
+ await expect(suite.planning.teams.create({ name: 'During close', fields: {} })).rejects.toThrow('scope-changed')
29
+ expect(suite.calls.length).toBe(calls)
30
+ await tick()
31
+ expect(done).toBe(false)
32
+ pause.release()
33
+ await Promise.all([closing, again])
34
+ await expect(writing).rejects.toThrow('scope-changed')
35
+ pause.restore()
36
+ for (const store of Object.values(stores)) expect((await store.list({}, { size: 0 })).items).toEqual([])
37
+ const fresh = await suite.planning.teams.create({ name: 'After close', fields: {} })
38
+ expect((await stores.teams.get(fresh.id!)).name).toBe('After close')
39
+ await suite.client.planning().close()
40
+ }
41
+ })
42
+
43
+ test('a failed clear blocks fresh work until an explicit retry clears the actual stores', async () => {
44
+ let identity = 'a'
45
+ const suite = await makeSuite({ resources: true, schemas: false, scopeKey: () => identity })
46
+ const stores = suite.client.planningStores!()
47
+ const old = await suite.planning.teams.create({ name: 'Kept on failure', fields: {} })
48
+ const remove = stores.teams.delete.bind(stores.teams)
49
+ let fail = true
50
+ stores.teams.delete = async (...args) => { if (fail) throw new Error('Storage unavailable'); return await remove(...args) }
51
+ await expect(suite.client.planning().close()).rejects.toThrow('Storage unavailable')
52
+ const calls = suite.calls.length
53
+ await expect(suite.planning.teams.get(old.id!)).rejects.toThrow('scope-changed')
54
+ expect(suite.calls.length).toBe(calls)
55
+ expect((await stores.teams.get(old.id!)).name).toBe('Kept on failure')
56
+ identity = 'b'
57
+ await expect(suite.planning.teams.list()).rejects.toThrow('scope-changed')
58
+ expect(suite.calls.length).toBe(calls)
59
+ fail = false
60
+ await suite.client.planning().close()
61
+ expect(await stores.teams.load(old.id!)).toBeNull()
62
+ expect((await suite.planning.teams.create({ name: 'Recovered', fields: {} })).name).toBe('Recovered')
63
+ stores.teams.delete = remove
64
+ await suite.client.planning().close()
65
+ })
66
+
67
+ test('a cached committed receipt is invalid after close while the server write remains committed', async () => {
68
+ let identity = 'a'
69
+ const suite = await makeSuite({ schemas: false, scopeKey: () => identity })
70
+ const project = await suite.project()
71
+ const receipt = await suite.planning.execute({ action: TransitionAction.Create, card: { kind: WorkcardKind.Card, type: STORY, parent: project.id!, title: 'Committed' } })
72
+ await suite.client.planning().close()
73
+ identity = 'b'
74
+ await expect(receipt.committed()).rejects.toThrow('scope-changed')
75
+ expect((await suite.local.cards.get(receipt.card!.id!)).title).toBe('Committed')
76
+ await suite.client.planning().close()
77
+ })
78
+
79
+ test('successive identities each clear after prior admitted writes without accepting the middle generation', async () => {
80
+ let identity = 'organization-a'
81
+ const suite = await makeSuite({ resources: true, schemas: false, scopeKey: () => identity, entityId: () => identity })
82
+ const stores = suite.client.planningStores!()
83
+ await suite.planning.teams.create({ name: 'A', fields: {} })
84
+ const pause = makeBarrier()
85
+ const remove = stores.teams.delete.bind(stores.teams)
86
+ stores.teams.delete = async (...args) => { await pause.hold(); return await remove(...args) }
87
+ identity = 'organization-b'
88
+ suite.client.planning().scopeKey()
89
+ await pause.entered
90
+ const middle = suite.planning.teams.create({ name: 'B', fields: {} })
91
+ void middle.catch(() => {})
92
+ await tick()
93
+ identity = 'organization-c'
94
+ const fresh = suite.planning.teams.create({ name: 'C', fields: {} })
95
+ pause.release()
96
+ await expect(middle).rejects.toThrow('scope-changed')
97
+ const team = await fresh
98
+ expect((await stores.teams.list({}, { size: 0 })).items.map(row => row.id)).toEqual([team.id])
99
+ expect(team.name).toBe('C')
100
+ stores.teams.delete = remove
101
+ await suite.client.planning().close()
102
+ })
103
+ })