@owlmeans/server-planning 0.1.18-rc.2 → 0.1.18-rc.21

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 (217) hide show
  1. package/README.md +40 -12
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-planning/SKILL.md +193 -18
  4. package/build/actions/cards.d.ts +3 -5
  5. package/build/actions/cards.d.ts.map +1 -1
  6. package/build/actions/cards.js +1 -1
  7. package/build/actions/cards.js.map +1 -1
  8. package/build/actions/execute.d.ts +24 -3
  9. package/build/actions/execute.d.ts.map +1 -1
  10. package/build/actions/execute.js +82 -8
  11. package/build/actions/execute.js.map +1 -1
  12. package/build/actions/schemas.d.ts +27 -5
  13. package/build/actions/schemas.d.ts.map +1 -1
  14. package/build/actions/schemas.js +67 -7
  15. package/build/actions/schemas.js.map +1 -1
  16. package/build/actions/specs.d.ts +2 -4
  17. package/build/actions/specs.d.ts.map +1 -1
  18. package/build/actions/specs.js.map +1 -1
  19. package/build/actions/types.d.ts +4 -0
  20. package/build/actions/types.d.ts.map +1 -0
  21. package/build/actions/types.js +2 -0
  22. package/build/actions/types.js.map +1 -0
  23. package/build/actions/watch.d.ts.map +1 -1
  24. package/build/actions/watch.js +4 -2
  25. package/build/actions/watch.js.map +1 -1
  26. package/build/conformance/assert.d.ts +20 -0
  27. package/build/conformance/assert.d.ts.map +1 -0
  28. package/build/conformance/assert.js +54 -0
  29. package/build/conformance/assert.js.map +1 -0
  30. package/build/conformance/cases.d.ts +7 -0
  31. package/build/conformance/cases.d.ts.map +1 -0
  32. package/build/conformance/cases.js +447 -0
  33. package/build/conformance/cases.js.map +1 -0
  34. package/build/conformance/consts.d.ts +19 -0
  35. package/build/conformance/consts.d.ts.map +1 -0
  36. package/build/conformance/consts.js +42 -0
  37. package/build/conformance/consts.js.map +1 -0
  38. package/build/conformance/fixtures.d.ts +14 -0
  39. package/build/conformance/fixtures.d.ts.map +1 -0
  40. package/build/conformance/fixtures.js +109 -0
  41. package/build/conformance/fixtures.js.map +1 -0
  42. package/build/conformance/index.d.ts +12 -0
  43. package/build/conformance/index.d.ts.map +1 -0
  44. package/build/conformance/index.js +11 -0
  45. package/build/conformance/index.js.map +1 -0
  46. package/build/conformance/types.d.ts +18 -0
  47. package/build/conformance/types.d.ts.map +1 -0
  48. package/build/conformance/types.js +2 -0
  49. package/build/conformance/types.js.map +1 -0
  50. package/build/consts.d.ts +5 -0
  51. package/build/consts.d.ts.map +1 -1
  52. package/build/consts.js +5 -0
  53. package/build/consts.js.map +1 -1
  54. package/build/definitions.d.ts +17 -0
  55. package/build/definitions.d.ts.map +1 -0
  56. package/build/definitions.js +211 -0
  57. package/build/definitions.js.map +1 -0
  58. package/build/executor/changes.d.ts +2 -2
  59. package/build/executor/changes.d.ts.map +1 -1
  60. package/build/executor/changes.js.map +1 -1
  61. package/build/executor/code.d.ts +3 -3
  62. package/build/executor/code.d.ts.map +1 -1
  63. package/build/executor/code.js +1 -1
  64. package/build/executor/code.js.map +1 -1
  65. package/build/executor/consts.local.d.ts +5 -0
  66. package/build/executor/consts.local.d.ts.map +1 -0
  67. package/build/executor/consts.local.js +6 -0
  68. package/build/executor/consts.local.js.map +1 -0
  69. package/build/executor/creator.d.ts +30 -0
  70. package/build/executor/creator.d.ts.map +1 -0
  71. package/build/executor/creator.js +46 -0
  72. package/build/executor/creator.js.map +1 -0
  73. package/build/executor/resolve.d.ts +31 -23
  74. package/build/executor/resolve.d.ts.map +1 -1
  75. package/build/executor/resolve.js +74 -15
  76. package/build/executor/resolve.js.map +1 -1
  77. package/build/executor/types.d.ts +24 -0
  78. package/build/executor/types.d.ts.map +1 -0
  79. package/build/executor/types.js +2 -0
  80. package/build/executor/types.js.map +1 -0
  81. package/build/executor/validate.d.ts +6 -5
  82. package/build/executor/validate.d.ts.map +1 -1
  83. package/build/executor/validate.js +18 -15
  84. package/build/executor/validate.js.map +1 -1
  85. package/build/executor.d.ts +10 -7
  86. package/build/executor.d.ts.map +1 -1
  87. package/build/executor.js +17 -14
  88. package/build/executor.js.map +1 -1
  89. package/build/facade.d.ts +20 -2
  90. package/build/facade.d.ts.map +1 -1
  91. package/build/facade.js +128 -18
  92. package/build/facade.js.map +1 -1
  93. package/build/helper.d.ts +3 -2
  94. package/build/helper.d.ts.map +1 -1
  95. package/build/helper.js +7 -3
  96. package/build/helper.js.map +1 -1
  97. package/build/index.d.ts +1 -0
  98. package/build/index.d.ts.map +1 -1
  99. package/build/index.js +1 -0
  100. package/build/index.js.map +1 -1
  101. package/build/projection.d.ts +2 -10
  102. package/build/projection.d.ts.map +1 -1
  103. package/build/projection.js +1 -1
  104. package/build/projection.js.map +1 -1
  105. package/build/queue/consts.d.ts +5 -0
  106. package/build/queue/consts.d.ts.map +1 -0
  107. package/build/queue/consts.js +5 -0
  108. package/build/queue/consts.js.map +1 -0
  109. package/build/queue/index.d.ts +4 -16
  110. package/build/queue/index.d.ts.map +1 -1
  111. package/build/queue/index.js +2 -4
  112. package/build/queue/index.js.map +1 -1
  113. package/build/queue/types.d.ts +13 -0
  114. package/build/queue/types.d.ts.map +1 -0
  115. package/build/queue/types.js +2 -0
  116. package/build/queue/types.js.map +1 -0
  117. package/build/registry.d.ts +2 -21
  118. package/build/registry.d.ts.map +1 -1
  119. package/build/registry.js +3 -1
  120. package/build/registry.js.map +1 -1
  121. package/build/service.d.ts +3 -16
  122. package/build/service.d.ts.map +1 -1
  123. package/build/service.js +9 -0
  124. package/build/service.js.map +1 -1
  125. package/build/store/commits.d.ts.map +1 -1
  126. package/build/store/commits.js +3 -1
  127. package/build/store/commits.js.map +1 -1
  128. package/build/store/composite.d.ts +1 -0
  129. package/build/store/composite.d.ts.map +1 -1
  130. package/build/store/composite.js +3 -0
  131. package/build/store/composite.js.map +1 -1
  132. package/build/store/consts.local.d.ts +2 -0
  133. package/build/store/consts.local.d.ts.map +1 -0
  134. package/build/store/consts.local.js +2 -0
  135. package/build/store/consts.local.js.map +1 -0
  136. package/build/store/fold.d.ts +4 -3
  137. package/build/store/fold.d.ts.map +1 -1
  138. package/build/store/fold.js +14 -6
  139. package/build/store/fold.js.map +1 -1
  140. package/build/store/memory.d.ts +12 -0
  141. package/build/store/memory.d.ts.map +1 -1
  142. package/build/store/memory.js +0 -0
  143. package/build/store/memory.js.map +1 -1
  144. package/build/store/types.d.ts +15 -1
  145. package/build/store/types.d.ts.map +1 -1
  146. package/build/store/types.local.d.ts +3 -0
  147. package/build/store/types.local.d.ts.map +1 -0
  148. package/build/store/types.local.js +2 -0
  149. package/build/store/types.local.js.map +1 -0
  150. package/build/types.d.ts +111 -1
  151. package/build/types.d.ts.map +1 -1
  152. package/build/types.local.d.ts +7 -0
  153. package/build/types.local.d.ts.map +1 -0
  154. package/build/types.local.js +2 -0
  155. package/build/types.local.js.map +1 -0
  156. package/build/utils/index.d.ts +1 -0
  157. package/build/utils/index.d.ts.map +1 -1
  158. package/build/utils/scope.d.ts +39 -2
  159. package/build/utils/scope.d.ts.map +1 -1
  160. package/build/utils/scope.js +79 -15
  161. package/build/utils/scope.js.map +1 -1
  162. package/build/utils/types.d.ts +8 -0
  163. package/build/utils/types.d.ts.map +1 -0
  164. package/build/utils/types.js +2 -0
  165. package/build/utils/types.js.map +1 -0
  166. package/package.json +28 -20
  167. package/src/actions/cards.ts +2 -6
  168. package/src/actions/execute.ts +95 -12
  169. package/src/actions/schemas.ts +81 -9
  170. package/src/actions/specs.ts +1 -2
  171. package/src/actions/types.ts +4 -0
  172. package/src/actions/watch.ts +5 -2
  173. package/src/conformance/assert.ts +59 -0
  174. package/src/conformance/cases.ts +522 -0
  175. package/src/conformance/consts.ts +44 -0
  176. package/src/conformance/fixtures.ts +121 -0
  177. package/src/conformance/index.ts +11 -0
  178. package/src/conformance/types.ts +20 -0
  179. package/src/consts.ts +6 -0
  180. package/src/definitions.ts +250 -0
  181. package/src/executor/changes.ts +2 -6
  182. package/src/executor/code.ts +3 -9
  183. package/src/executor/consts.local.ts +8 -0
  184. package/src/executor/creator.ts +51 -0
  185. package/src/executor/resolve.ts +84 -40
  186. package/src/executor/types.ts +25 -0
  187. package/src/executor/validate.ts +22 -26
  188. package/src/executor.ts +21 -21
  189. package/src/facade.ts +143 -25
  190. package/src/helper.ts +7 -3
  191. package/src/index.ts +1 -0
  192. package/src/projection.ts +4 -14
  193. package/src/queue/consts.ts +5 -0
  194. package/src/queue/index.ts +6 -21
  195. package/src/queue/types.ts +14 -0
  196. package/src/registry.ts +5 -29
  197. package/src/service.ts +13 -20
  198. package/src/store/commits.ts +5 -4
  199. package/src/store/composite.ts +3 -0
  200. package/src/store/consts.local.ts +1 -0
  201. package/src/store/fold.ts +15 -12
  202. package/src/store/memory.ts +0 -0
  203. package/src/store/types.local.ts +3 -0
  204. package/src/store/types.ts +16 -1
  205. package/src/types.local.ts +7 -0
  206. package/src/types.ts +126 -4
  207. package/src/utils/index.ts +1 -0
  208. package/src/utils/scope.ts +97 -17
  209. package/src/utils/types.ts +8 -0
  210. package/tests/access.spec.ts +160 -0
  211. package/tests/conformance.spec.ts +47 -0
  212. package/tests/create-example.spec.ts +202 -0
  213. package/tests/creator.spec.ts +257 -0
  214. package/tests/entrypoints.spec.ts +5 -0
  215. package/tests/narrowing.spec.ts +94 -0
  216. package/tests/plugins.spec.ts +4 -3
  217. package/tests/writes.spec.ts +140 -0
@@ -0,0 +1,202 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AppType, makeBasicContext } from '@owlmeans/context'
3
+ import type { BasicConfig } from '@owlmeans/context'
4
+ import {
5
+ CommitState, IntrinsicStatus, PLANNING_SERVICE, SchemaInvalid, TransitionAction, WorkcardKind,
6
+ } from '@owlmeans/planning'
7
+ import type {
8
+ PlanningPlugin, StatusFlowSchema, TransitionExecution, WithPlanningService, WorkcardTypeSchema,
9
+ } from '@owlmeans/planning'
10
+ import { appendPlanningService, makePlanningService } from '../src/service.js'
11
+ import { makeMemoryPlanningStore } from '../src/store/memory.js'
12
+ import type { MemoryPlanningStoreOptions } from '../src/store/types.js'
13
+ import type { PlanningHostService } from '../src/types.js'
14
+
15
+ /**
16
+ * The create example the `planning`, `server-planning` and `planning-postgres` skills show, run as
17
+ * written — and the wrong forms beside it, each answered the way the skills say.
18
+ */
19
+
20
+ const SHED: PlanningPlugin = {
21
+ name: 'shed-planning',
22
+ schemas: {
23
+ flows: [
24
+ {
25
+ id: 'shed:site', version: 1,
26
+ statuses: [
27
+ { key: 'open', intrinsic: IntrinsicStatus.Planned, initial: true },
28
+ { key: 'closed', intrinsic: IntrinsicStatus.Closed, terminal: true },
29
+ ],
30
+ transitions: [{ name: 'close', from: ['open'], to: 'closed' }],
31
+ },
32
+ {
33
+ id: 'shed:loan', version: 1,
34
+ statuses: [
35
+ { key: 'available', intrinsic: IntrinsicStatus.Planned, initial: true, label: 'Available' },
36
+ { key: 'lent', intrinsic: IntrinsicStatus.InProgress, label: 'Lent out' },
37
+ { key: 'retired', intrinsic: IntrinsicStatus.Closed, terminal: true },
38
+ ],
39
+ transitions: [
40
+ { name: 'lend', from: ['available'], to: 'lent', label: 'Lend', explicit: true },
41
+ { name: 'return', from: ['lent'], to: 'available', label: 'Return', explicit: true },
42
+ { name: 'retire', from: '*', to: 'retired' },
43
+ ],
44
+ label: 'Loan',
45
+ },
46
+ ],
47
+ types: [
48
+ {
49
+ type: 'shed:shed', kind: WorkcardKind.Project, version: 1, label: 'Tool shed',
50
+ fields: { type: 'object' }, flows: ['shed:site'], specifications: [],
51
+ cardTypes: ['shed:tool'], scopedCardTypes: true,
52
+ },
53
+ {
54
+ type: 'shed:tool', kind: WorkcardKind.Card, version: 1, label: 'Tool',
55
+ fields: { type: 'object', properties: { brand: { type: 'string' } }, additionalProperties: false },
56
+ flows: ['shed:loan'], specifications: [],
57
+ },
58
+ ],
59
+ },
60
+ }
61
+
62
+ const cfg = (): BasicConfig => ({ ready: false, service: 'shed-tests', type: AppType.Backend, services: {} })
63
+
64
+ /** A target's shape: the service registered as a plain service, no `ctx.planning()`. */
65
+ const bootTarget = async (store: MemoryPlanningStoreOptions = {}) => {
66
+ const memory = makeMemoryPlanningStore({ schemas: true, ...store })
67
+ const context = makeBasicContext(cfg())
68
+ context.registerService(makePlanningService({ store: memory, plugins: [SHED] }))
69
+ context.configure()
70
+ await context.init()
71
+
72
+ return { context, memory }
73
+ }
74
+
75
+ const scope = { entityId: 'org-1', profileId: 'profile-1' }
76
+
77
+ describe('creating a card — the documented form', () => {
78
+ test('scoped facade → create a project and a card under it with { wait: true } → read back', async () => {
79
+ const { context } = await bootTarget()
80
+ const planning = context.service<PlanningHostService>(PLANNING_SERVICE).for(scope)
81
+
82
+ const shed = await planning.execute({
83
+ action: TransitionAction.Create,
84
+ card: { kind: WorkcardKind.Project, type: 'shed:shed', title: 'Maple Street shed' },
85
+ }, { wait: true })
86
+ const shedId = shed.card!.id!
87
+
88
+ const drill = await planning.execute({
89
+ action: TransitionAction.Create,
90
+ card: {
91
+ kind: WorkcardKind.Card, type: 'shed:tool', parent: shedId, title: 'Cordless drill',
92
+ fields: { brand: 'Acme' },
93
+ },
94
+ }, { wait: true })
95
+
96
+ expect(drill.transition.commit.state).toBe(CommitState.Committed)
97
+ expect(drill.card).toMatchObject({ title: 'Cordless drill', status: 'available', parent: shedId })
98
+
99
+ const again = await planning.cards.get(drill.card!.id!)
100
+ expect(again.fields).toEqual({ brand: 'Acme' })
101
+ const tools = await planning.cards.list({ parent: shedId, type: 'shed:tool' })
102
+ expect(tools.items.map(tool => tool.title)).toEqual(['Cordless drill'])
103
+
104
+ const lent = await planning.execute({
105
+ action: TransitionAction.Transit, card: drill.card!.id!, transition: 'lend',
106
+ }, { wait: true })
107
+ expect(lent.card?.status).toBe('lent')
108
+ })
109
+
110
+ test('a data-defined type and flow with the exact keys, then a card of that type', async () => {
111
+ const { context } = await bootTarget()
112
+ const planning = context.service<PlanningHostService>(PLANNING_SERVICE).for(scope)
113
+ const shedId = (await planning.execute({
114
+ action: TransitionAction.Create,
115
+ card: { kind: WorkcardKind.Project, type: 'shed:shed', title: 'Maple Street shed' },
116
+ }, { wait: true })).card!.id!
117
+
118
+ const records = await planning.definitions!.define({
119
+ flows: [{
120
+ id: 'shed:repair', version: 1, label: 'Repair',
121
+ statuses: [
122
+ { key: 'reported', intrinsic: IntrinsicStatus.Planned, initial: true, label: 'Reported' },
123
+ { key: 'fixed', intrinsic: IntrinsicStatus.Closed, terminal: true, label: 'Fixed' },
124
+ ],
125
+ transitions: [{ name: 'fix', from: ['reported'], to: 'fixed', label: 'Mark fixed', explicit: true }],
126
+ }],
127
+ types: [{
128
+ type: 'shed:repair', kind: WorkcardKind.Card, version: 1, label: 'Repair ticket',
129
+ fields: { type: 'object', properties: { tool: { type: 'string' } }, additionalProperties: false },
130
+ flows: ['shed:repair'], specifications: [],
131
+ }],
132
+ }, { project: shedId })
133
+ expect(records.map(record => `${record.kind}:${record.key}:${record.version}`))
134
+ .toEqual(['flow:shed:repair:1', 'type:shed:repair:1'])
135
+
136
+ const ticket = await planning.execute({
137
+ action: TransitionAction.Create,
138
+ card: { kind: WorkcardKind.Card, type: 'shed:repair', parent: shedId, title: 'Drill chuck slips' },
139
+ }, { wait: true })
140
+ expect(ticket.card?.status).toBe('reported')
141
+ })
142
+ })
143
+
144
+ describe('creating a card — the wrong forms', () => {
145
+ test('`draft:` in place of `card:` is refused before anything is written', async () => {
146
+ const { context } = await bootTarget()
147
+ const planning = context.service<PlanningHostService>(PLANNING_SERVICE).for(scope)
148
+ const wrong = { action: TransitionAction.Create, draft: { kind: WorkcardKind.Project, type: 'shed:shed', title: 'x' } }
149
+
150
+ await expect(planning.execute(wrong as unknown as TransitionExecution, { wait: true }))
151
+ .rejects.toThrow('planning:malformed:create-without-draft')
152
+ })
153
+
154
+ test('`wait: true` inside the execution is ignored in process — the receipt is back before the commit', async () => {
155
+ const { context, memory } = await bootTarget({ sync: false })
156
+ const planning = context.service<PlanningHostService>(PLANNING_SERVICE).for(scope)
157
+ const create: TransitionExecution = {
158
+ action: TransitionAction.Create,
159
+ card: { kind: WorkcardKind.Project, type: 'shed:shed', title: 'Maple Street shed' },
160
+ }
161
+
162
+ const early = await planning.execute({ ...create, wait: true } as TransitionExecution)
163
+ expect(early.transition.commit.state).toBe(CommitState.Pending)
164
+ expect(early.card).toBeUndefined()
165
+
166
+ // In the second argument it holds until the commit lands — here, until the store folds.
167
+ const folding = setInterval(() => void memory.flush(), 5)
168
+ try {
169
+ const held = await planning.execute({
170
+ ...create, card: { ...(create.card as object), title: 'Oak Lane shed' },
171
+ } as TransitionExecution, { wait: true })
172
+ expect(held.transition.commit.state).toBe(CommitState.Committed)
173
+ expect(held.card?.title).toBe('Oak Lane shed')
174
+ } finally {
175
+ clearInterval(folding)
176
+ }
177
+ })
178
+
179
+ test('`ctx.planning()` exists only where appendPlanningService ran; the service alias always works', async () => {
180
+ const { context } = await bootTarget()
181
+ expect((context as Partial<WithPlanningService>).planning).toBeUndefined()
182
+ expect(context.service<PlanningHostService>(PLANNING_SERVICE).for(scope).scope.entityId).toBe('org-1')
183
+
184
+ const appended = appendPlanningService(makeBasicContext(cfg()), { plugins: [SHED] })
185
+ appended.configure()
186
+ await appended.init()
187
+ expect(appended.planning()).toBe(appended.service<PlanningHostService>(PLANNING_SERVICE))
188
+ })
189
+
190
+ test('a declaration with `name` / `key` instead of `label` / `type` / `id` is SchemaInvalid', async () => {
191
+ const { context } = await bootTarget()
192
+ const planning = context.service<PlanningHostService>(PLANNING_SERVICE).for(scope)
193
+ const flow = SHED.schemas!.flows![1]
194
+ const type = SHED.schemas!.types![1] as WorkcardTypeSchema
195
+
196
+ const named = { ...flow, id: 'shed:named', name: 'Loan' } as unknown as StatusFlowSchema
197
+ await expect(planning.definitions!.define({ flows: [named] })).rejects.toBeInstanceOf(SchemaInvalid)
198
+
199
+ const keyed = { ...type, type: 'shed:keyed', key: 'shed:keyed', name: 'Tool' } as unknown as WorkcardTypeSchema
200
+ await expect(planning.definitions!.define({ types: [keyed] })).rejects.toBeInstanceOf(SchemaInvalid)
201
+ })
202
+ })
@@ -0,0 +1,257 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { CommitState, makePlanningProtocols, PlanningError, TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
+ import type { PlanningFacade, Transition, TransitionExecution, WorkcardChanges, WorkcardDraft } from '@owlmeans/planning'
4
+ import { executePlanning, wireExecution } from '../src/actions/index.js'
5
+ import { assertCreatorFixed, creatorOf, withCreator } from '../src/executor.js'
6
+ import { createProject, createTask, ENTITY, invoke, makeTestPlanning, PROFILE, PROJECT, session, TASK } from './context.js'
7
+
8
+ const tree = makePlanningProtocols({ base: { alias: 'test:planning' }, guards: 'test-guard' })
9
+
10
+ const draftOf = (exec: TransitionExecution): WorkcardDraft => exec.card as WorkcardDraft
11
+
12
+ describe('@owlmeans/server-planning — a create\'s createdBy', () => {
13
+ test('an in-process create is stamped with the scope\'s profile, on the card and in the log', async () => {
14
+ const { facade } = await makeTestPlanning()
15
+ const planning = facade()
16
+ const project = await createProject(planning)
17
+ const receipt = await createTask(planning, project.id!)
18
+
19
+ expect(project.createdBy).toBe(PROFILE)
20
+ expect(receipt.card!.createdBy).toBe(PROFILE)
21
+ expect(receipt.transition.changes?.createdBy).toBe(PROFILE)
22
+ expect((await planning.cards.get(receipt.card!.id!)).createdBy).toBe(PROFILE)
23
+ })
24
+
25
+ test('an explicit createdBy wins over the scope', async () => {
26
+ const { facade } = await makeTestPlanning()
27
+ const planning = facade()
28
+ const project = await createProject(planning, 'Delegated', { createdBy: 'owner-7' })
29
+
30
+ expect(project.createdBy).toBe('owner-7')
31
+ expect((await createTask(planning, project.id!, 'Theirs', { createdBy: 'owner-8' })).card!.createdBy).toBe('owner-8')
32
+ })
33
+
34
+ test('the subject falls back to the userId, then to the scope\'s actor', async () => {
35
+ const { service } = await makeTestPlanning()
36
+
37
+ const byUser = await createProject(service.for({ entityId: ENTITY, userId: 'user-9' }))
38
+ const byActor = await createProject(service.for({ entityId: ENTITY, actor: { profileId: 'agent-profile' } }))
39
+
40
+ expect(byUser.createdBy).toBe('user-9')
41
+ expect(byActor.createdBy).toBe('agent-profile')
42
+ })
43
+
44
+ test('a scope that names nobody leaves createdBy unset', async () => {
45
+ const { service } = await makeTestPlanning()
46
+
47
+ const system = await createProject(service.for({ entityId: ENTITY, service: 'importer', channel: 'job' }))
48
+
49
+ expect(system.createdBy).toBeUndefined()
50
+ expect(Object.keys(system)).not.toContain('createdBy')
51
+ })
52
+
53
+ test('later writes by another profile never move it, and the caller\'s execution is not touched', async () => {
54
+ const { service, facade } = await makeTestPlanning()
55
+ const exec: TransitionExecution = {
56
+ card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Kept' }, action: TransitionAction.Create,
57
+ }
58
+ const project = (await facade().execute(exec, { wait: true })).card!
59
+ const other = service.for({ entityId: ENTITY, profileId: 'profile-2' })
60
+
61
+ await other.execute({ card: project.id!, action: TransitionAction.Update, changes: { title: 'Renamed' } }, { wait: true })
62
+ await other.execute({ card: project.id!, action: TransitionAction.Transit, transition: 'activate' }, { wait: true })
63
+
64
+ expect((await other.cards.get(project.id!)).createdBy).toBe(PROFILE)
65
+ expect(draftOf(exec).createdBy).toBeUndefined()
66
+ })
67
+
68
+ test('a before plugin already sees the stamped createdBy', async () => {
69
+ const seen: (string | undefined)[] = []
70
+ const { facade } = await makeTestPlanning({
71
+ plugins: [{
72
+ name: 'owner-check', order: 10,
73
+ before: async exec => { seen.push(typeof exec.card === 'object' ? exec.card.createdBy : 'update') },
74
+ }],
75
+ })
76
+
77
+ await createProject(facade())
78
+
79
+ expect(seen).toEqual([PROFILE])
80
+ })
81
+
82
+ test('the wire action is unchanged: the body\'s claim is replaced by the request\'s subject', async () => {
83
+ const { context } = await makeTestPlanning()
84
+ const execute = executePlanning(tree.execute)
85
+ const body = { card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Wire', createdBy: 'intruder' }, action: TransitionAction.Create, wait: true }
86
+
87
+ const signed = await invoke(execute, context, { ...session(), body })
88
+ expect(signed.card.createdBy).toBe(PROFILE)
89
+
90
+ const byUser = await invoke(execute, context, {
91
+ ...session(ENTITY, { auth: { type: 'test', userId: 'user-1', entitySlug: ENTITY, scopes: ['*'] } }), body,
92
+ })
93
+ expect(byUser.card.createdBy).toBe('user-1')
94
+
95
+ const anonymous = await invoke(execute, context, {
96
+ ...session(ENTITY, { auth: { type: 'test', entitySlug: ENTITY, scopes: ['*'] } }), body,
97
+ })
98
+ expect(anonymous.card.createdBy).toBeUndefined()
99
+ })
100
+
101
+ test('wireExecution still answers the execution the executor will run', () => {
102
+ const body = { card: { kind: WorkcardKind.Project, type: PROJECT, title: 'W', createdBy: 'intruder' }, action: TransitionAction.Create }
103
+
104
+ expect(draftOf(wireExecution(body, { entityId: ENTITY, profileId: PROFILE, userId: 'user-1' })).createdBy).toBe(PROFILE)
105
+ expect(draftOf(wireExecution(body, { entityId: ENTITY, userId: 'user-1' })).createdBy).toBe('user-1')
106
+ expect(draftOf(wireExecution(body, { entityId: ENTITY })).createdBy).toBeUndefined()
107
+ })
108
+
109
+ test('creatorOf and withCreator', () => {
110
+ expect(creatorOf({ entityId: ENTITY, profileId: 'p', userId: 'u', actor: { profileId: 'a' } })).toBe('p')
111
+ expect(creatorOf({ entityId: ENTITY, profileId: '', userId: 'u' })).toBe('u')
112
+ expect(creatorOf({ entityId: ENTITY, actor: { userId: 'au', service: 's' } })).toBe('au')
113
+ expect(creatorOf({ entityId: ENTITY, service: 's' })).toBeUndefined()
114
+
115
+ const update: TransitionExecution = { card: 'card-1', action: TransitionAction.Update, changes: { title: 'T' } }
116
+ expect(withCreator(update, { entityId: ENTITY, profileId: 'p' })).toBe(update)
117
+ const blank: TransitionExecution = { card: { kind: WorkcardKind.Project, type: PROJECT, title: 'T', createdBy: '' }, action: TransitionAction.Create }
118
+ expect(draftOf(withCreator(blank, { entityId: ENTITY, profileId: 'p' })).createdBy).toBe('p')
119
+ })
120
+ })
121
+
122
+ /** The refusal of a write that names a provenance field, and the log it left untouched. */
123
+ const refusedOf = async (run: Promise<unknown>): Promise<string> => {
124
+ const error = await run.then(() => null, (error: Error) => error)
125
+ expect(error).toBeInstanceOf(PlanningError)
126
+ return error!.message
127
+ }
128
+
129
+ const logOf = async (planning: PlanningFacade, card: string): Promise<number> =>
130
+ (await planning.transitions.list({ card })).total
131
+
132
+ describe('@owlmeans/server-planning — createdBy never moves after the create', () => {
133
+ test('an in-process update, transit, unset or create overlay naming it is refused, and nothing is appended', async () => {
134
+ const { facade } = await makeTestPlanning()
135
+ const planning = facade()
136
+ const project = await createProject(planning)
137
+ const task = (await createTask(planning, project.id!)).card!
138
+ const attempts: TransitionExecution[] = [
139
+ { card: task.id!, action: TransitionAction.Update, changes: { createdBy: 'intruder' } },
140
+ { card: task.id!, action: TransitionAction.Update, changes: { title: 'Mine now', createdBy: 'intruder' } },
141
+ { card: task.id!, action: TransitionAction.Update, changes: { createdBy: null } as unknown as WorkcardChanges },
142
+ { card: task.id!, action: TransitionAction.Update, unset: ['createdBy'] },
143
+ { card: task.id!, action: TransitionAction.Transit, transition: 'start', changes: { createdBy: 'intruder' } },
144
+ { card: { kind: WorkcardKind.Card, type: TASK, parent: project.id!, title: 'Overlay' }, action: TransitionAction.Create, changes: { createdBy: 'intruder' } },
145
+ ]
146
+
147
+ for (const exec of attempts) {
148
+ expect(await refusedOf(planning.execute(exec, { wait: true }))).toBe('planning:immutable:createdBy')
149
+ }
150
+ expect(await refusedOf(planning.execute({ card: task.id!, action: TransitionAction.Update, changes: { createdAt: '2001-01-01T00:00:00.000Z' } as WorkcardChanges })))
151
+ .toBe('planning:immutable:createdAt')
152
+ expect(await refusedOf(planning.execute({ card: task.id!, action: TransitionAction.Update, unset: ['createdAt'] })))
153
+ .toBe('planning:immutable:createdAt')
154
+
155
+ const card = await planning.cards.get(task.id!)
156
+ expect([card.createdBy, card.title, card.status, card.seq]).toEqual([PROFILE, 'A task', 'todo', 1])
157
+ expect(await logOf(planning, task.id!)).toBe(1)
158
+ expect((await planning.cards.list({ parent: project.id })).total).toBe(1)
159
+ })
160
+
161
+ test('a before plugin cannot move it either', async () => {
162
+ const { facade } = await makeTestPlanning({
163
+ plugins: [{
164
+ name: 'reassign', order: 10,
165
+ before: async exec => exec.action === TransitionAction.Update
166
+ ? { ...exec, changes: { ...exec.changes, createdBy: 'plugin-owner' } }
167
+ : undefined,
168
+ }],
169
+ })
170
+ const planning = facade()
171
+ const project = await createProject(planning)
172
+
173
+ expect(await refusedOf(planning.execute({ card: project.id!, action: TransitionAction.Update, changes: { title: 'Renamed' } })))
174
+ .toBe('planning:immutable:createdBy')
175
+ expect((await planning.cards.get(project.id!)).createdBy).toBe(PROFILE)
176
+ })
177
+
178
+ test('a trusted in-process create names it; the owner then moves it no more than anyone else', async () => {
179
+ const { facade } = await makeTestPlanning()
180
+ const planning = facade()
181
+ const project = await createProject(planning, 'On behalf', { createdBy: 'owner-7' })
182
+ await planning.execute({ card: project.id!, action: TransitionAction.Update, changes: { title: 'Still theirs' } }, { wait: true })
183
+
184
+ expect(await refusedOf(planning.execute({ card: project.id!, action: TransitionAction.Update, changes: { createdBy: PROFILE } })))
185
+ .toBe('planning:immutable:createdBy')
186
+ expect((await planning.cards.get(project.id!))).toMatchObject({ title: 'Still theirs', createdBy: 'owner-7', seq: 2 })
187
+ })
188
+
189
+ test('the wire refuses it on an update, an unset and a create\'s changes; the draft claim is still dropped', async () => {
190
+ const { context, facade } = await makeTestPlanning()
191
+ const execute = executePlanning(tree.execute)
192
+ const created = await invoke(execute, context, {
193
+ ...session(), body: { card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Wire', createdBy: 'intruder' }, action: TransitionAction.Create, wait: true },
194
+ })
195
+ const id = created.card.id as string
196
+ const bodies = [
197
+ { card: id, action: TransitionAction.Update, changes: { createdBy: 'intruder' }, wait: true },
198
+ { card: id, action: TransitionAction.Update, changes: { title: 'Hijacked', createdBy: 'intruder' }, wait: true },
199
+ { card: id, action: TransitionAction.Update, unset: ['createdBy'], wait: true },
200
+ { card: id, action: TransitionAction.Transit, transition: 'activate', changes: { createdBy: 'intruder' }, wait: true },
201
+ { card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Overlay' }, action: TransitionAction.Create, changes: { createdBy: 'intruder' }, wait: true },
202
+ ]
203
+
204
+ for (const body of bodies) {
205
+ expect(await refusedOf(invoke(execute, context, { ...session(), body }))).toBe('planning:immutable:createdBy')
206
+ }
207
+ expect(await refusedOf(invoke(execute, context, { ...session(), body: { card: id, action: TransitionAction.Update, unset: ['createdAt'] } })))
208
+ .toBe('planning:immutable:createdAt')
209
+
210
+ expect(created.card.createdBy).toBe(PROFILE)
211
+ const card = await facade().cards.get(id)
212
+ expect([card.createdBy, card.title, card.status, card.seq]).toEqual([PROFILE, 'Wire', 'open', 1])
213
+ expect(await logOf(facade(), id)).toBe(1)
214
+ expect((await facade().cards.list({ kind: WorkcardKind.Project })).total).toBe(1)
215
+ })
216
+
217
+ test('a legacy transition that named it still folds, and the card keeps committing past it', async () => {
218
+ const { facade, store } = await makeTestPlanning()
219
+ const planning = facade()
220
+ const project = await createProject(planning)
221
+ const task = (await createTask(planning, project.id!)).card!
222
+ const legacy = async (patch: Partial<Transition>) => {
223
+ const seq = await store.transitions!.nextSeq(task.id!, null)
224
+ return await store.transitions!.append({
225
+ entityId: task.entityId, card: task.id!, kind: task.kind, type: task.type, project: project.id!, seq,
226
+ action: TransitionAction.Update, changes: {}, actor: { profileId: 'intruder' },
227
+ at: '2001-01-01T00:00:00.000Z', commit: { state: CommitState.Pending }, ...patch,
228
+ })
229
+ }
230
+
231
+ const renamed = await legacy({ changes: { title: 'Legacy', createdBy: 'intruder' } })
232
+ const cleared = await legacy({ unset: ['createdBy'] })
233
+ await store.cards.project(task.id!)
234
+
235
+ expect((await planning.commits.status(renamed.id!)).state).toBe(CommitState.Committed)
236
+ expect((await planning.commits.status(cleared.id!)).state).toBe(CommitState.Committed)
237
+ const folded = await planning.cards.get(task.id!)
238
+ expect([folded.title, folded.seq, folded.createdBy]).toEqual(['Legacy', 3, undefined])
239
+
240
+ await planning.execute({ card: task.id!, action: TransitionAction.Update, changes: { title: 'After' } }, { wait: true })
241
+ expect(await refusedOf(planning.execute({ card: task.id!, action: TransitionAction.Update, changes: { createdBy: PROFILE } })))
242
+ .toBe('planning:immutable:createdBy')
243
+ expect((await planning.cards.get(task.id!))).toMatchObject({ title: 'After', seq: 4 })
244
+ })
245
+
246
+ test('assertCreatorFixed', () => {
247
+ const draft = { kind: WorkcardKind.Project, type: PROJECT, title: 'T', createdBy: 'owner' }
248
+
249
+ expect(() => assertCreatorFixed({ card: draft, action: TransitionAction.Create })).not.toThrow()
250
+ expect(() => assertCreatorFixed({ card: 'c', action: TransitionAction.Update, changes: { title: 'T', createdBy: undefined } })).not.toThrow()
251
+ expect(() => assertCreatorFixed({ card: 'c', action: TransitionAction.Update, unset: ['fields.createdBy'] })).not.toThrow()
252
+ expect(() => assertCreatorFixed({ card: 'c', action: TransitionAction.Update, changes: { fields: { createdBy: 'x' } } })).not.toThrow()
253
+ expect(() => assertCreatorFixed({ card: 'c', action: TransitionAction.Update, changes: { createdBy: 'x' } })).toThrow('planning:immutable:createdBy')
254
+ expect(() => assertCreatorFixed({ card: 'c', action: TransitionAction.Delete, unset: ['createdBy'] })).toThrow('planning:immutable:createdBy')
255
+ expect(() => assertCreatorFixed({ card: draft, action: TransitionAction.Create, changes: { createdBy: 'x' } })).toThrow('planning:immutable:createdBy')
256
+ })
257
+ })
@@ -16,6 +16,11 @@ describe('@owlmeans/server-planning — entrypoints', () => {
16
16
 
17
17
  expect(bindings.length).toBe(declared.length)
18
18
  expect(new Set(bindings.map(binding => binding.alias))).toEqual(new Set(declared.map(protocol => protocol.alias)))
19
+
20
+ const scoped = makePlanningProtocols({ base: { alias: 'test:planning' }, guards: 'test-guard', definitions: true })
21
+ const all = protocols(scoped as never)
22
+ expect(servePlanningEntrypoints(scoped).map(binding => binding.alias).sort()).toEqual(all.map(protocol => protocol.alias).sort())
23
+ expect(all.length).toBe(declared.length + 1)
19
24
  })
20
25
 
21
26
  test('execute takes the actor and createdBy from the request, never from the body', async () => {
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { WorkcardKind, WorkcardNotFound } from '@owlmeans/planning'
3
+ import type { PlanningFacade, Workcard } from '@owlmeans/planning'
4
+ import { createProject, createTask, ENTITY, makeTestPlanning, PROFILE } from './context.js'
5
+
6
+ /**
7
+ * Two projects with a task each, a design document on each task and a plan on the first project —
8
+ * a specification's `parents` hold only its own card, so a scope narrowed to one project sees the
9
+ * task's document through the task.
10
+ */
11
+ const fixture = async () => {
12
+ const planning = await makeTestPlanning()
13
+ const owner = planning.facade()
14
+ const granted = await createProject(owner, 'Granted')
15
+ const hidden = await createProject(owner, 'Hidden')
16
+ const seen = (await createTask(owner, granted.id!, 'Seen')).card!
17
+ const unseen = (await createTask(owner, hidden.id!, 'Unseen')).card!
18
+ const write = async (card: Workcard, category: string, body: string): Promise<Workcard> =>
19
+ (await (await owner.model(card)).write(category, body, { wait: true })).card!
20
+ const design = await write(seen, 'design', '{"steps":[1]}')
21
+ const hiddenDesign = await write(unseen, 'design', '{"steps":[2]}')
22
+ const plan = await write(granted, 'plan', '{"steps":[3]}')
23
+ const scoped = (projects: string[]): PlanningFacade =>
24
+ planning.service.for({ entityId: ENTITY, profileId: PROFILE, channel: 'test', projects })
25
+
26
+ return { ...planning, owner, granted, hidden, seen, unseen, design, hiddenDesign, plan, scoped }
27
+ }
28
+
29
+ const ids = (items: Workcard[]): string[] => items.map(card => card.id!).sort()
30
+
31
+ describe('@owlmeans/server-planning — a narrowed scope lists what it reads', () => {
32
+ test('a granted card\'s specification is listed and counted as it is read', async () => {
33
+ const { seen, unseen, design, hiddenDesign, plan, granted, scoped } = await fixture()
34
+ const member = scoped([granted.id!])
35
+
36
+ expect((await member.cards.get(design.id!)).id).toBe(design.id!)
37
+ await expect(member.cards.get(hiddenDesign.id!)).rejects.toBeInstanceOf(WorkcardNotFound)
38
+
39
+ const underCard = await member.cards.list({ parent: seen.id!, kind: WorkcardKind.Specification })
40
+ expect(ids(underCard.items)).toEqual([design.id!])
41
+ expect(underCard.total).toBe(1)
42
+ expect(await member.cards.count({ parent: seen.id!, kind: WorkcardKind.Specification })).toBe(1)
43
+ expect((await member.cards.list({ parent: unseen.id!, kind: WorkcardKind.Specification })).items).toEqual([])
44
+ expect(await member.cards.count({ parent: unseen.id! })).toBe(0)
45
+
46
+ const everyDocument = await member.cards.list({ kind: WorkcardKind.Specification })
47
+ expect(ids(everyDocument.items)).toEqual([design.id!, plan.id!].sort())
48
+ expect(await member.cards.count({ kind: WorkcardKind.Specification })).toBe(2)
49
+ expect(ids((await member.cards.list({ kind: WorkcardKind.Card })).items)).toEqual([seen.id!])
50
+
51
+ expect(ids((await member.cards.list({ parent: seen.id!, category: 'design' })).items)).toEqual([design.id!])
52
+ expect((await member.specifications.list(seen.id!)).total).toBe(1)
53
+ // A summary counts a parent's cards, never its documents.
54
+ expect(Object.keys(await member.cards.summary([granted.id!, seen.id!, unseen.id!]))).toEqual([granted.id!])
55
+ })
56
+
57
+ test('every card the narrowed scope reads is the one it lists, and no other', async () => {
58
+ const { owner, granted, scoped } = await fixture()
59
+ const member = scoped([granted.id!])
60
+ const every = { size: 0, kind: [WorkcardKind.Project, WorkcardKind.Card, WorkcardKind.Specification] }
61
+ const everything = (await owner.cards.list(every)).items
62
+ const listed = new Set(ids((await member.cards.list(every)).items))
63
+
64
+ expect(everything.length).toBe(7)
65
+ for (const card of everything) {
66
+ expect([card.id, (await member.cards.load(card.id!)) != null]).toEqual([card.id, listed.has(card.id!)])
67
+ }
68
+ expect(listed.size).toBe(4)
69
+ expect(await member.cards.count(every)).toBe(listed.size)
70
+ })
71
+
72
+ test('a scope with no project, or another one, sees none of the granted project\'s documents', async () => {
73
+ const { seen, design, plan, hidden, scoped } = await fixture()
74
+
75
+ for (const outsider of [scoped([]), scoped([hidden.id!])]) {
76
+ await expect(outsider.cards.get(design.id!)).rejects.toBeInstanceOf(WorkcardNotFound)
77
+ expect((await outsider.cards.list({ parent: seen.id!, kind: WorkcardKind.Specification })).items).toEqual([])
78
+ expect(await outsider.cards.count({ parent: seen.id! })).toBe(0)
79
+ const documents = ids((await outsider.cards.list({ kind: WorkcardKind.Specification })).items)
80
+ expect(documents).not.toContain(design.id!)
81
+ expect(documents).not.toContain(plan.id!)
82
+ }
83
+ expect((await scoped([]).cards.list({ size: 0, kind: [WorkcardKind.Card, WorkcardKind.Specification] })).total).toBe(0)
84
+ })
85
+
86
+ test('an unnarrowed scope sees every document of the organization', async () => {
87
+ const { owner, unseen, design, hiddenDesign, plan } = await fixture()
88
+
89
+ expect(ids((await owner.cards.list({ kind: WorkcardKind.Specification })).items))
90
+ .toEqual([design.id!, hiddenDesign.id!, plan.id!].sort())
91
+ expect(await owner.cards.count({ kind: WorkcardKind.Specification })).toBe(3)
92
+ expect(ids((await owner.cards.list({ parent: unseen.id!, kind: WorkcardKind.Specification })).items)).toEqual([hiddenDesign.id!])
93
+ })
94
+ })
@@ -1,3 +1,4 @@
1
+ import { configureLog, logConfig } from '@owlmeans/log'
1
2
  import { describe, expect, test } from 'bun:test'
2
3
  import { CommitState, PlanningRefused, TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
4
  import { makeMemoryPlanningStore } from '../src/store/memory.js'
@@ -66,8 +67,8 @@ describe('@owlmeans/server-planning — plugins', () => {
66
67
  ],
67
68
  }, { sync: false })
68
69
  const planning = facade()
69
- const errors = console.error
70
- console.error = () => undefined
70
+ const { level } = logConfig()
71
+ configureLog({ level: 'silent' })
71
72
  try {
72
73
  const receipt = await planning.execute({ card: { kind: WorkcardKind.Project, type: PROJECT, title: 'Later' }, action: TransitionAction.Create })
73
74
  expect(receipt.transition.commit.state).toBe(CommitState.Pending)
@@ -80,7 +81,7 @@ describe('@owlmeans/server-planning — plugins', () => {
80
81
  expect(seen).toEqual([`${receipt.transition.card}:committed:test`])
81
82
  expect((await receipt.committed({ timeout: 100 }))!.title).toBe('Later')
82
83
  } finally {
83
- console.error = errors
84
+ configureLog({ level })
84
85
  }
85
86
  })
86
87
  })