@owlmeans/server-planning 0.1.18-rc.13 → 0.1.18-rc.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,13 +10,16 @@ the `@owlmeans/server-planning/store` subpath.
10
10
  ## Installation
11
11
 
12
12
  ```sh
13
- bun add @owlmeans/server-planning@^0.1.18-rc.13 @owlmeans/planning@^0.1.18-rc.11 ajv
13
+ bun add @owlmeans/server-planning@^0.1.18-rc.14 @owlmeans/planning@^0.1.18-rc.12 ajv
14
14
  ```
15
15
 
16
16
  ## Concepts
17
17
 
18
- - **Facade** — `context.planning().for(scope)`: every read and write of one organization entity,
19
- with no scope argument on any method.
18
+ - **Facade** — `ctx.service<PlanningHostService>(PLANNING_SERVICE).for(scope)`: every read and
19
+ write of one organization entity, with no scope argument on any method. `context.planning()` is
20
+ the same service, but only on a context that went through `appendPlanningService`; a service
21
+ registered as a plain one (`makePlanningService()`, a generated target's `services/planning.ts`)
22
+ has no `context.planning`.
20
23
  - **Executor** — the only write path: normalize → idempotency → resolve → plugins' `before` →
21
24
  validate → id → code → changes → seq CAS → append → project → receipt. Nothing is appended by a
22
25
  refusal.
@@ -125,6 +128,13 @@ const cards = await planningFor(ctx, req, { channel: 'web' }).cards.list({ paren
125
128
 
126
129
  ## Common pitfalls
127
130
 
131
+ - A create's draft goes under `card` — `draft` is refused as `planning:malformed:create-without-draft`.
132
+ - `{ wait: true }` is the SECOND argument of `execute`; inside the execution it is ignored and the
133
+ receipt returns before the commit.
134
+ - A type or flow declaration has no `name` or `key`: the display name is `label`, the key is `type`
135
+ (a type) or `id` (a flow). `definitions.define` refuses anything else with `SchemaInvalid`; a code
136
+ plugin's registry does not check, so keep the literals typed.
137
+
128
138
  - `after` hooks run where the transition is FOLDED, once per commit — register the same plugins in
129
139
  every folding process, and never run hooks from a commit-bus subscription.
130
140
  - The memory store is per-process heap: wrong for multiple processes or restarts.
@@ -153,7 +163,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
153
163
  your project's skill store (`.agents/skills/`):
154
164
 
155
165
  ```sh
156
- npx @owlmeans/agent-skills@^0.1.18-rc.41
166
+ npx @owlmeans/agent-skills@^0.1.18-rc.42
157
167
  ```
158
168
 
159
169
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-planning",
4
- "version": "0.1.18-rc.13",
5
- "generatedAt": "2026-09-28T02:06:37.869Z",
4
+ "version": "0.1.18-rc.14",
5
+ "generatedAt": "2026-09-29T08:01:58.111Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/server-planning
9
9
 
10
10
  **Layer:** Server
11
- **Install:** `"@owlmeans/server-planning": "^0.1.18-rc.13"` in `dependencies` (`ajv` is a peer)
11
+ **Install:** `"@owlmeans/server-planning": "^0.1.18-rc.14"` in `dependencies` (`ajv` is a peer)
12
12
 
13
13
  The general implementation of `@owlmeans/planning`: the planning service (a plugin host), the
14
14
  scoped facade, the executor every write goes through, the in-memory reference store, the commit
@@ -61,8 +61,68 @@ context.registerEntrypoints(servePlanningEntrypoints(planningProtocols, {
61
61
  `ensurePlanningService(ctx).use(plugin)` works from `makeContext`, before `init()`.
62
62
  - Call `appendPlanningService` BEFORE any `ensurePlanningService`: `ensure` registers a default
63
63
  host when none exists, and a later `append` replaces it together with whatever was `use`d on it.
64
- - In code: `ctx.planning().for({ entityId, profileId?, channel?, actor? })` → a `PlanningFacade`
65
- with no scope argument on any method.
64
+ - **The facade:** `ctx.service<PlanningHostService>(PLANNING_SERVICE).for({ entityId, profileId?,
65
+ channel?, actor? })` → a `PlanningFacade` with no scope argument on any method. That form works
66
+ however the service was registered. `ctx.planning()` is only a shortcut that `appendPlanningService`
67
+ / `appendPostgresPlanning` install (typed `WithPlanningService`); a context that registered
68
+ `makePlanningService()` / `makePostgresPlanningService()` as a plain service — a generated target's
69
+ `services/planning.ts` — has no `ctx.planning` at all. In a request handler use
70
+ `planningFor(ctx, req)`: the entity comes from `requireEntityKey(req)`, never the token.
71
+
72
+ ## Creating and reading a card
73
+
74
+ ```ts
75
+ import { IntrinsicStatus, PLANNING_SERVICE, TransitionAction, WorkcardKind } from '@owlmeans/planning'
76
+ import type { PlanningHostService } from '@owlmeans/server-planning'
77
+
78
+ const planning = ctx.service<PlanningHostService>(PLANNING_SERVICE).for({ entityId, profileId })
79
+
80
+ const shed = await planning.execute({
81
+ action: TransitionAction.Create,
82
+ card: { kind: WorkcardKind.Project, type: 'shed:shed', title: 'Maple Street shed' },
83
+ }, { wait: true })
84
+ const drill = await planning.execute({
85
+ action: TransitionAction.Create,
86
+ card: { kind: WorkcardKind.Card, type: 'shed:tool', parent: shed.card!.id!, title: 'Cordless drill',
87
+ fields: { brand: 'Acme' } },
88
+ }, { wait: true }) // drill.card — the committed record, status 'available'
89
+
90
+ await planning.cards.get(drill.card!.id!)
91
+ await planning.cards.list({ parent: shed.card!.id!, type: 'shed:tool' })
92
+ await planning.execute({ action: TransitionAction.Transit, card: drill.card!.id!, transition: 'lend' }, { wait: true })
93
+
94
+ // A card type and a flow as DATA, scoped to this shed — `definitions` exists only where the store
95
+ // keeps schemas (planning-postgres; the memory store with `schemas: true`), and the shed's cards may
96
+ // use them when its project type says `scopedCardTypes: true` (or lists the type in `cardTypes`).
97
+ await planning.definitions!.define({
98
+ flows: [{
99
+ id: 'shed:repair', version: 1, label: 'Repair',
100
+ statuses: [
101
+ { key: 'reported', intrinsic: IntrinsicStatus.Planned, initial: true, label: 'Reported' },
102
+ { key: 'fixed', intrinsic: IntrinsicStatus.Closed, terminal: true, label: 'Fixed' },
103
+ ],
104
+ transitions: [{ name: 'fix', from: ['reported'], to: 'fixed', label: 'Mark fixed', explicit: true }],
105
+ }],
106
+ types: [{
107
+ type: 'shed:repair', kind: WorkcardKind.Card, version: 1, label: 'Repair ticket',
108
+ fields: { type: 'object', properties: { tool: { type: 'string' } }, additionalProperties: false },
109
+ flows: ['shed:repair'], specifications: [],
110
+ }],
111
+ }, { project: shed.card!.id! })
112
+ ```
113
+
114
+ | Wrong | What happens | Right |
115
+ |---|---|---|
116
+ | `execute({ action, draft: { … } })` | refused: `planning:malformed:create-without-draft` | the draft goes under `card` |
117
+ | `execute({ …, wait: true })` | in process the key is ignored: the receipt returns before the commit, with no `card` | `execute({ … }, { wait: true })` — only the wire body (`ExecuteRequest`) carries `wait` inside |
118
+ | `ctx.planning().for(…)` in a target | `ctx.planning is not a function` | `ctx.service<PlanningHostService>(PLANNING_SERVICE).for(…)` |
119
+ | `{ id, name: 'Repair', … }` on a flow, `{ key, name, … }` on a type | `define` refuses with `SchemaInvalid` (closed schemas); a code plugin's registry does not validate, so the key is silently dropped | a flow's key is `id`, a type's is `type`, the display name is `label` on both |
120
+ | `action: 'create'` | a type error — `TransitionAction` is an enum | `action: TransitionAction.Create` |
121
+
122
+ Every wrong form is a compile error against the typed facade (`PlanningFacade`,
123
+ `TransitionExecution`, `WorkcardTypeSchema`); it reaches run time only through a helper or a context
124
+ typed `any`, so never type one that way. `tests/create-example.spec.ts` runs this example and each
125
+ wrong form as written.
66
126
 
67
127
  ## The executor, step by step
68
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/server-planning",
3
- "version": "0.1.18-rc.13",
3
+ "version": "0.1.18-rc.14",
4
4
  "license": "MIT",
5
5
  "description": "The general planning implementation — the planning service and its plugin/middleware registry, the transition executor, the in-memory store, the protocol handlers and the commit feed.",
6
6
  "type": "module",
@@ -45,19 +45,19 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@owlmeans/auth": "^0.1.18-rc.36",
48
- "@owlmeans/auth-common": "^0.1.18-rc.40",
48
+ "@owlmeans/auth-common": "^0.1.18-rc.41",
49
49
  "@owlmeans/basic-ids": "^0.1.18-rc.34",
50
50
  "@owlmeans/context": "^0.1.18-rc.32",
51
51
  "@owlmeans/entrypoint": "^0.1.18-rc.37",
52
52
  "@owlmeans/error": "^0.1.18-rc.36",
53
- "@owlmeans/planning": "^0.1.18-rc.11",
54
- "@owlmeans/queue": "^0.1.18-rc.34",
55
- "@owlmeans/resource": "^0.1.18-rc.36",
53
+ "@owlmeans/planning": "^0.1.18-rc.12",
54
+ "@owlmeans/queue": "^0.1.18-rc.35",
55
+ "@owlmeans/resource": "^0.1.18-rc.37",
56
56
  "@owlmeans/route": "^0.1.18-rc.33",
57
- "@owlmeans/server-api": "^0.1.18-rc.44",
58
- "@owlmeans/server-context": "^0.1.18-rc.37",
57
+ "@owlmeans/server-api": "^0.1.18-rc.45",
58
+ "@owlmeans/server-context": "^0.1.18-rc.38",
59
59
  "@owlmeans/server-entrypoint": "^0.1.18-rc.36",
60
- "@owlmeans/server-socket": "^0.1.18-rc.47",
60
+ "@owlmeans/server-socket": "^0.1.18-rc.48",
61
61
  "@owlmeans/socket": "^0.1.18-rc.35"
62
62
  },
63
63
  "peerDependencies": {
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "devDependencies": {
68
68
  "@owlmeans/dep-config": "workspace:*",
69
- "@owlmeans/test": "^0.1.18-rc.32",
69
+ "@owlmeans/test": "^0.1.18-rc.33",
70
70
  "@types/bun": "^1.4.0",
71
71
  "@types/node": "^26.1.0",
72
72
  "ajv": "^8.17.1",
@@ -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
+ })