@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/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # @owlmeans/client-planning
2
2
 
3
3
  The client half of OwlMeans planning: a `PlanningFacade` over the protocol tree a server mounted
4
- with [`@owlmeans/server-planning`](../server-planning), an optional state mirror of cards, links and
5
- commits, and a commit wait that subscribes first and then long-polls. It is React-free, so the same
4
+ with [`@owlmeans/server-planning`](../server-planning), optional state mirrors of cards, links,
5
+ commits, assignees, teams, comments and mentions, and a commit wait that subscribes first and then long-polls. It is React-free, so the same
6
6
  code runs in a browser app and in a Node client. Use `@owlmeans/server-planning` instead wherever the
7
7
  planning service runs in-process.
8
8
 
9
9
  ## Installation
10
10
 
11
11
  ```bash
12
- bun add @owlmeans/client-planning@^0.1.18-rc.8
12
+ bun add @owlmeans/client-planning@^0.1.18
13
13
  ```
14
14
 
15
15
  ## Concepts
@@ -17,14 +17,46 @@ bun add @owlmeans/client-planning@^0.1.18-rc.8
17
17
  - **Remote facade** — the same `PlanningFacade` interface the server answers; every method is one
18
18
  entrypoint call, and `modelOf(record, facade)` models behave identically on both sides.
19
19
  - **One id space** — projects, cards and specifications share ONE card store; a list reaches it
20
- through `syncCards(store, items, where)`, never `replace()`.
20
+ through `syncHelper.syncCards(store, items, where)`, never `replace()`.
21
21
  - **Commit** — `execute()` returns a receipt for a pending transition; `receipt.committed()` /
22
22
  `commits.wait()` resolve with the folded card, or throw `CommitTimeout` / `CommitFailed`.
23
23
  - **Socket seam** — the commit socket is opened by an injected `PlanningSocketOpener`; without one
24
24
  the client long-polls.
25
- - **`head > seq`** — a card with a transition allocated and not yet folded; `applyReceipt` marks it
25
+ - **`head > seq`** — a card with a transition allocated and not yet folded; the mirror's `applyReceipt` marks it
26
26
  the moment the server answers.
27
27
 
28
+ ## Forms under a strict Content Security Policy
29
+
30
+ Use the exact resolved card or assignee type's field schema:
31
+
32
+ ```typescript
33
+ import { makePlanningFieldValidator } from '@owlmeans/client-planning'
34
+
35
+ const form = makePlanningFieldValidator(resolvedType.fields)
36
+ const checked = form.validateFields(fields)
37
+ if (checked.valid) {
38
+ // Submit this same fields object through the stock planning facade.
39
+ }
40
+ ```
41
+
42
+ The runtime interpreter supports dynamic organization and project definitions without `eval` or
43
+ `new Function`. It preserves the schema and payload, nested objects, required fields, enum/const,
44
+ dates, references, compositions and AJV nullable semantics; string formats use native
45
+ `ajv-formats` full-mode predicates. Errors contain field `path`, `keyword`, `schemaPath` and a
46
+ bounded message without submitted values. The server's authoritative AJV validates every write.
47
+
48
+ The schema contract is Draft 7 plus `$defs` and `nullable`, with native AJV reference-sibling
49
+ semantics. References must resolve within the supplied schema. Malformed schemas, missing
50
+ references, same-instance reference cycles, other drafts/custom validation keywords, unknown or
51
+ numeric formats, format-limit extensions and `multipleOf` throw `SchemaInvalid` at preparation.
52
+ The interpreter's floating tolerance and native AJV's multiple semantics differ, so that keyword
53
+ is explicitly refused. JSON fields cannot contain dot/dollar keys or non-JSON values. Standard
54
+ annotations and `x-` metadata remain annotations. At most 64 validation errors are returned.
55
+
56
+ Surface preparation errors and keep save disabled. Do not remove constraints, choose another
57
+ schema or relax CSP. Registry `validator` / `assigneeValidator` compile with native AJV and need
58
+ code generation; browser forms under strict CSP use this API instead.
59
+
28
60
  ## Usage
29
61
 
30
62
  Wire the client into a context with the server's own tree:
@@ -36,9 +68,14 @@ import { appendPlanningClient, appendPlanningStores } from '@owlmeans/client-pla
36
68
  const planningProtocols = makePlanningProtocols({
37
69
  base: { alias: 'app:api:planning', path: '/planning', service: API },
38
70
  guards: DEFAULT_GUARD,
71
+ resources: true,
72
+ definitions: true,
39
73
  })
40
74
 
41
- appendPlanningClient(context, { protocols: planningProtocols, poll: 20 })
75
+ appendPlanningClient(context, {
76
+ protocols: planningProtocols, poll: 20,
77
+ scopeKey: () => `${currentEntitySlug}:${currentSessionId}`,
78
+ })
42
79
  appendPlanningStores(context)
43
80
  ```
44
81
 
@@ -77,18 +114,61 @@ Give a browser its commit socket through the host's own opener:
77
114
  ```typescript
78
115
  appendPlanningClient(context, {
79
116
  protocols: planningProtocols,
80
- socket: async (protocol, request) => await ws(context.entrypoint(protocol), request),
117
+ socket: async (protocol, request) => await socketClientHelper.ws(context.entrypoint(protocol), request),
118
+ })
119
+ ```
120
+
121
+ ## Auxiliary resources and session lifecycle
122
+
123
+ The remote facade supports the same `assignees`, `teams`, `comments` and `mentions` methods as
124
+ the server, including compare-and-set writes, canonical team membership/project attachment,
125
+ effective project assignees and mention-cache rebuilding. `definitions` supports assignee types
126
+ when the server mounts writable schemas. Relationships include endpoint resource kinds and named
127
+ inverse views; models resolve schemas through their nearest primary project.
128
+
129
+ `appendPlanningStores` registers four auxiliary mirrors beside cards, links and commits. Reads
130
+ and writes through the facade update those mirrors without replacing newer versions. A resource
131
+ feed refreshes the requested subsets through the stock API:
132
+
133
+ ```ts
134
+ import { makePlanningResourceFeed } from '@owlmeans/client-planning'
135
+
136
+ const resources = makePlanningResourceFeed(context, {
137
+ assignees: { retired: false, size: 0 }, teams: { size: 0 },
138
+ comments: { card: cardId, size: 0 }, mentions: { card: cardId, size: 0 },
139
+ refresh: 5_000,
81
140
  })
141
+ await resources.ready
142
+ // Read context.planningStores().assignees, teams, comments and mentions.
143
+ await resources.stop()
82
144
  ```
83
145
 
146
+ `scopeKey` identifies the current organization slug and authenticated session. A changed key
147
+ cancels old requests and feeds, drains local mutations and clears mirrors; capture a new facade
148
+ and feed for the new session. `await context.planning().close()` releases feeds, subscriptions,
149
+ commit waits and sockets, including carriers that open late. Stopped requests cannot repopulate
150
+ cleared mirrors, even when a transport ignores cancellation.
151
+
152
+ The server derives authorization scope from credentials. Wire records omit native organization
153
+ `entityId`; the client hydrates empty advisory metadata for the shared record shape. Never use
154
+ that placeholder to authorize a request, and never send an organization record id on the wire.
155
+
84
156
  ## API
85
157
 
86
158
  - `appendPlanningClient(context, options)`, `makePlanningClientService(context, options)`
87
159
  - `makeRemoteFacade(context, protocols, scope, opts)`, `makeRemoteCommitSource(context, protocols, opts?)`
88
- - `appendPlanningStores(context, aliases?)`, `planningStoresOf(context)`, `syncCards`, `syncLinks`
89
- - `applyCommitEvent(stores, event, facade?)`, `applyReceipt(stores, view)`, `applyCards(stores, cards)`
160
+ - `appendPlanningStores(context, aliases?)`
161
+ - `syncHelper` — `.syncCards(store, items, where?, opts?)`, `.syncLinks(store, items, where?, opts?)`
162
+ - `planningMirrorOf(stores)` (`makePlanningMirror`) — `.applyCommitEvent(event, facade?)`,
163
+ `.applyReceipt(view)`, `.applyCards(cards)`
90
164
  - `makePlanningFeed(context, opts?)`
91
- - `planningOf(context, scope?)`, `planningModelOf(context, card, scope?)`
165
+ - `makePlanningResourceFeed(context, opts?)`, `PlanningResourceFeedOptions`
166
+ - `makePlanningFieldValidator(schema)`, `PlanningFieldValidator`, `PlanningFieldValidation`, `PlanningFieldError`
167
+ - `makePlanningClientLifecycle`, `PlanningClientLifecycle`; service `close()` and `scopeKey`
168
+ - `planningContextOf(context)` (`makePlanningContextHelper`) — `.facade(scope?)`, `.model(card, scope?)`,
169
+ `.stores()`
170
+ - The former plain functions (`syncCards`, `applyCards`, `planningOf`, …) remain as deprecated
171
+ delegates.
92
172
  - `CARDS`, `LINKS`, `COMMITS`, `DEFAULT_STORE_ALIASES`, `LONG_POLL_GRACE`, `EARLY_POLL_LADDER`, `EARLY_POLL_MS`
93
173
  - Types: `PlanningClientOptions`, `PlanningClientService`, `WithPlanningClient`,
94
174
  `PlanningSocketOpener`, `RemoteCommitSource`, `RemoteCommitSourceOptions`, `RemoteFacadeOptions`,
@@ -103,6 +183,9 @@ appendPlanningClient(context, {
103
183
  - `CommitTimeout` leaves the transition pending — re-wait or re-read, do not re-execute without a `key`.
104
184
  - A client refuses `store()`, `committed()` and plugins carrying hooks with `PlanningUnsupported`.
105
185
  - Mount one feed per screen; every other reader uses the store.
186
+ - Auxiliary updates/removals require `{ version: record.version }`. Their `fields` replace the
187
+ previous object; card `changes.fields` merge.
188
+ - After a scope/session change, acquire new facades and feeds. Old ones are deliberately stale.
106
189
 
107
190
  ## Related packages
108
191
 
@@ -119,7 +202,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
119
202
  your project's skill store (`.agents/skills/`):
120
203
 
121
204
  ```sh
122
- npx @owlmeans/agent-skills@^0.1.18-rc.35
205
+ npx @owlmeans/agent-skills@^0.1.18
123
206
  ```
124
207
 
125
208
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "package": "@owlmeans/client-planning",
4
- "version": "0.1.18-rc.8",
5
- "generatedAt": "2026-09-22T21:57:18.823Z",
4
+ "version": "0.1.18",
5
+ "generatedAt": "2026-10-28T00:31:34.741Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -10,7 +10,65 @@
10
10
  "name": "client-planning",
11
11
  "category": "package-specific",
12
12
  "file": "skills/client-planning/SKILL.md",
13
- "canonicalPath": ".agents/skills/client-planning/SKILL.md"
13
+ "canonicalPath": ".agents/skills/client-planning/SKILL.md",
14
+ "parts": [
15
+ {
16
+ "name": "aux-mirrors",
17
+ "file": "skills/client-planning/parts/aux-mirrors.md",
18
+ "description": "The assignee, team, comment and mention mirrors and resource feeds, and the authenticated scope lifecycle (scopeKey, close, lifecycle.run and lifecycle.mutate).",
19
+ "purposes": [
20
+ "state",
21
+ "auth"
22
+ ],
23
+ "mentions": [
24
+ "makePlanningResourceFeed",
25
+ "scopeKey",
26
+ "lifecycle.run",
27
+ "lifecycle.mutate",
28
+ "registerFeed",
29
+ "client:scope-changed",
30
+ "PlanningReply"
31
+ ]
32
+ },
33
+ {
34
+ "name": "mirror",
35
+ "file": "skills/client-planning/parts/mirror.md",
36
+ "description": "The state mirror's one id space and fold rules, makePlanningFeed, and optimistic writes with head over seq.",
37
+ "purposes": [
38
+ "state",
39
+ "list"
40
+ ],
41
+ "mentions": [
42
+ "appendPlanningStores",
43
+ "planningMirrorOf",
44
+ "applyCommitEvent",
45
+ "applyReceipt",
46
+ "applyCards",
47
+ "syncHelper",
48
+ "syncCards",
49
+ "syncLinks",
50
+ "makePlanningFeed",
51
+ "PlanningFeed",
52
+ "useStoreList"
53
+ ]
54
+ },
55
+ {
56
+ "name": "validation",
57
+ "file": "skills/client-planning/parts/validation.md",
58
+ "description": "CSP-safe browser validation of a served field schema with makePlanningFieldValidator — the supported keywords, refusals and limits, and the external references behind them.",
59
+ "purposes": [
60
+ "form"
61
+ ],
62
+ "mentions": [
63
+ "makePlanningFieldValidator",
64
+ "validateFields",
65
+ "@cfworker/json-schema",
66
+ "JSON_SCHEMA_DRAFT_07",
67
+ "unsafe-eval",
68
+ "SchemaInvalid"
69
+ ]
70
+ }
71
+ ]
14
72
  }
15
73
  ]
16
74
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: client-planning
3
- description: How to use @owlmeans/client-planning — appendPlanningClient for the remote planning facade, appendPlanningStores and applyCommitEvent for the state mirror, makePlanningFeed, commits.wait (subscribe then long-poll), and the models that are the same objects the server uses. Auto-invoked when reading planning data in a browser or a Node client, mounting the commit feed, or wiring optimistic transitions.
3
+ description: How to use @owlmeans/client-planning — appendPlanningClient for the remote planning facade, appendPlanningStores and planningMirrorOf for the state mirror, makePlanningFeed, commits.wait (subscribe then long-poll), and the models that are the same objects the server uses. Auto-invoked when reading planning data in a browser or a Node client, mounting the commit feed, or wiring optimistic transitions.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/client-planning
9
9
 
10
10
  **Layer:** Client
11
- **Install:** `"@owlmeans/client-planning": "^0.1.18-rc.8"` in `dependencies`
11
+ **Install:** `"@owlmeans/client-planning": "^0.1.18"` in `dependencies`
12
12
 
13
13
  The client half of OwlMeans planning. It answers the `PlanningFacade` interface of
14
14
  `@owlmeans/planning` over the protocol tree a server mounted with `@owlmeans/server-planning`, keeps
@@ -24,13 +24,15 @@ CLI). React hooks over the mirror are `useStoreModel` / `useStoreList` from `@ow
24
24
  | `makePlanningClientService(context, options)` | The service itself, for a host that registers it on its own |
25
25
  | `makeRemoteFacade(context, protocols, scope, opts)` | One facade — every method is one entrypoint call |
26
26
  | `makeRemoteCommitSource(context, protocols, opts?)` | `status` / `subscribe` / `wait` over `commit.get` and `commit.events` |
27
- | `appendPlanningStores(context, aliases?)` / `planningStoresOf(context)` | The state mirror, and a lookup that answers `null` without one |
28
- | `syncCards(store, items, where?, opts?)` / `syncLinks(...)` | Make the mirror agree with a list WITHIN a scope |
29
- | `applyCommitEvent(stores, event, facade?)` / `applyReceipt(stores, view)` / `applyCards(stores, cards)` | The folds |
27
+ | `makeRemoteDefinitions(context, protocols, opts?)` | Data-defined types and flows over a tree declared with `definitions: true` |
28
+ | `appendPlanningStores(context, aliases?)` / `planningContextOf(context).stores()` | The state mirror, and a lookup that answers `null` without one |
29
+ | `syncHelper.syncCards(store, items, where?, opts?)` / `syncHelper.syncLinks(...)` | Make the mirror agree with a list WITHIN a scope |
30
+ | `planningMirrorOf(stores).applyCommitEvent(event, facade?)` / `.applyReceipt(view)` / `.applyCards(cards)` | The folds |
30
31
  | `makePlanningFeed(context, opts?)` | Subscribe, seed, fold, refresh — `{ connected, seeded, error, ready, refresh, stop }` |
31
- | `planningOf(context, scope?)` / `planningModelOf(context, card, scope?)` | The facade / a model with the schemas loaded |
32
+ | `planningContextOf(context).facade(scope?)` / `.model(card, scope?)` | The facade / a model with the schemas loaded |
33
+ | `makePlanningFieldValidator(schema)` | CSP-safe validation of an exact served field schema; `{ validateFields }` |
32
34
  | `CARDS`, `LINKS`, `COMMITS` | Store aliases (`planning-card-state`, `planning-link-state`, `planning-commit-state`) |
33
- | Types | `PlanningClientOptions`, `PlanningClientService`, `WithPlanningClient`, `PlanningSocketOpener`, `RemoteCommitSource`, `PlanningStores`, `PlanningStoreAliases`, `WithPlanningStores`, `PlanningCommitRecord`, `PlanningFeed`, `PlanningFeedOptions`, `PlanningFeedState`, `SyncOptions` |
35
+ | Types | `PlanningClientOptions`, `PlanningClientService`, `WithPlanningClient`, `PlanningSocketOpener`, `RemoteCommitSource`, `RemoteDefinitions`, `RemoteDefinitionsOptions`, `PlanningStores`, `PlanningStoreAliases`, `WithPlanningStores`, `PlanningCommitRecord`, `PlanningFeed`, `PlanningFeedOptions`, `PlanningFeedState`, `SyncOptions`, `PlanningContextHelper`, `PlanningMirror`, `SyncHelper` |
34
36
 
35
37
  ## Wiring
36
38
 
@@ -59,36 +61,66 @@ appendPlanningStores(context) // a browser mirror; a Node client usually
59
61
  - `bind: false` when the host already bound the tree.
60
62
  - The service answers under `PLANNING_SERVICE`, the alias the server's service uses, so
61
63
  `context.planning().for(scope)` is the same call in a handler and in a screen. The scope is
62
- **advisory**: the server takes the entity and the actor from the credential. The execution's own
63
- `actor` is never sent.
64
+ **advisory** — it changes nothing the server decides: the server takes the entity and the actor
65
+ from the credential. The execution's own `actor` is never sent.
64
66
  - A client runs no middleware and folds nothing: `store()`, `committed()` and a plugin carrying
65
67
  `before`/`after`/`store`/`mintCode` answer `PlanningUnsupported`. `use()` accepts a schemas-only
66
- plugin, layered over the server's bundle.
68
+ plugin, layered over the server's bundle; its `schemas.app` merges over the served declarations
69
+ (`bundle.app`) in plugin order, and a malformed one is refused (`SchemaInvalid`) before anything
70
+ changes.
67
71
  - `timeout` (ms) bounds every call that is not a long poll; a long poll's deadline is its own hold
68
72
  plus `LONG_POLL_GRACE`.
69
73
 
70
74
  ### The schema bundle
71
75
 
72
76
  `model()` needs the flows and types (`can()`, `available()`, `statusOf()` read the registry with no
73
- round trip). The bundle is fetched in the background once the context is **Ready** and a failure
77
+ round trip). The bundle also carries the application's declarations (`registry.app()`), so
78
+ `registry.fieldsSchema(type)` / `validator(type)` include its base card fields and a definition's
79
+ `app` bag reads back as written. The bundle is fetched in the background once the context is **Ready** and a failure
74
80
  there is swallowed — a browser that is not signed in yet cannot read it, and a context must never
75
81
  fail or wait over it. `model()` and `service.loadSchemas()` load it on first use regardless, and a
76
82
  failed load is retried by the next caller. `schemas: false` skips the background load.
77
83
 
84
+ ### Data-defined types and flows
85
+
86
+ A tree declared with `definitions: true` (the server's store holds data-defined schemas) gives the
87
+ service and every facade `definitions`: `registry(project?)` / `bundle(project?)` read
88
+ `schema.list?project=` and are cached per project; `putType`/`putFlow` (compare-and-set on the
89
+ declaration's `version`), `define`, `seed` and `retire` go through `schema.define` and drop the
90
+ whole cache — an organization-wide write reaches every project's layer — and make the next
91
+ `model()` reload the service's own bundle. `records` is the server's alone (`PlanningUnsupported`).
92
+ `model(card)` of a card resolves its type in its project's layer (a project's own id, a card's
93
+ nearest primary project ancestor); a specification's type is always code's. A tree without `definitions` has none of this.
94
+
95
+ ## Mounting in a target
96
+
97
+ The web half of the target mount (`planning` → Mounting in a target, in that skill's `mounting` part).
98
+
99
+ - `appendPlanningClient(context, { protocols, bind: false, schemas: false })` — the target binds
100
+ its whole api tree itself, and a signed-out visitor may read nothing, so the bundle loads on
101
+ first use — plus `appendPlanningStores(context)`. No `socket`: the target serves no commit socket,
102
+ so `commits.wait` long-polls.
103
+ - Screens read the mirror with `useStoreList` / `useStoreModel` (`@owlmeans/client`), never a
104
+ fetched array kept in component state; lists reach the store through `syncHelper.syncCards` or
105
+ `makePlanningFeed` (with `refresh`), NEVER `replace()` — the one card store holds every kind.
106
+ - Every write is `facade.execute(...)` with a `key` (a retried click or a double submit answers
107
+ the first receipt) and `{ wait: true }` where the screen shows the result.
108
+ - Never call a route with an empty id; a picker offers no archived parent (`intrinsic: closed`).
109
+
78
110
  ## Reading
79
111
 
80
112
  ```typescript
81
113
  const planning = context.planning().for()
82
114
 
83
- const stories = await planning.cards.list({ parent: projectId, type: STORY, sort: ['order'], size: 0 })
84
- const board = await planning.cards.summary([projectId])
85
- const brief = await planning.specifications.current(projectId, 'specification')
86
- const story = await planning.model(storyId)
87
- story.available().map(rule => rule.name)
115
+ const tools = await planning.cards.list({ parent: shedId, type: 'shed:tool', sort: ['order'], size: 0 })
116
+ const board = await planning.cards.summary([shedId])
117
+ const rules = await planning.specifications.current(shedId, 'house-rules')
118
+ const drill = await planning.model(drillId)
119
+ drill.available().map(rule => rule.name)
88
120
  ```
89
121
 
90
122
  - The facade takes the rich `WorkcardQuery`; it encodes the scalar wire shape itself
91
- (`encodeWorkcardQuery` and its twins). Never pass a wire shape to the facade.
123
+ (`wireHelper.encodeWorkcardQuery` and its twins). Never pass a wire shape to the facade.
92
124
  - `cards.load(id)` answers `null` for a missing card — and for another entity's card, which the
93
125
  server deliberately reports the same way.
94
126
  - `cards.count(query)` is a one-row list read for its `total`; the tree declares no count route.
@@ -96,54 +128,6 @@ story.available().map(rule => rule.name)
96
128
  project-wide read answers `PlanningUnsupported` on a client.
97
129
  - `size: 0` is "no limit"; a paged server answers 100 rows when no size is asked for.
98
130
 
99
- ## The mirror — one id space
100
-
101
- `appendPlanningStores` registers ONE card store for every kind. Projects, cards and specifications
102
- share an id space on the server, so they share one store here: a store per kind lets a card list
103
- that reloads drop the projects beside it.
104
-
105
- That is also why a list reaches the store through **`syncCards(store, items, where)`, never
106
- `replace()`**: every card given is written, every card matching `where` that the list does not name
107
- is dropped, and everything outside `where` is left alone. An unchanged card is not rewritten, so a
108
- periodic re-seed wakes no subscriber. `opts.keep` protects ids a commit wrote while the list was in
109
- flight.
110
-
111
- The fold rules, shared by `syncCards`, `applyCards`, `applyCommitEvent` and `commits.wait`:
112
-
113
- - an OLDER `seq` never overwrites a newer record — frames and list answers race;
114
- - a committed `delete` removes the row and every link touching it; a project's delete also drops
115
- the rows whose `parents` hold it (the server purged them in the same fold);
116
- - a frame for an id the store never saw still writes a row;
117
- - a `failed` commit leaves the card alone and records the reason in the commit store;
118
- - a frame without `record` (a cross-process bus carries ids only) is re-read through the facade —
119
- without one, only the commit row is written;
120
- - a committed `link`/`unlink` re-lists the card's outgoing links; links written together with a
121
- `create` arrive with the next link seed.
122
-
123
- ## The feed
124
-
125
- ```typescript
126
- const feed = makePlanningFeed(context, {
127
- query: { parent: projectId, type: STORY },
128
- filter: { project: projectId },
129
- refresh: 15_000,
130
- onChange: state => setState(state),
131
- })
132
- await feed.ready // never rejects — read feed.error
133
- // …
134
- await feed.stop() // stops folding; the shared socket stays open
135
- ```
136
-
137
- 1. Subscribes to commits FIRST (the socket, when an opener exists).
138
- 2. Seeds: `cards.list` (unpaged unless the query pages) → `syncCards` over `where`
139
- (`criteriaOf(query)` by default). A PAGED seed only writes — a page cannot say what does not
140
- exist.
141
- 3. Folds every frame with `applyCommitEvent`, and re-seeds every `refresh` ms — the authoritative
142
- backstop to a socket that can drop frames.
143
-
144
- `connected` reports whether a socket was open when the feed subscribed; `seeded` tells "nothing
145
- there" from "not loaded yet". A React hook wraps the feed in an effect and stops it on unmount.
146
-
147
131
  ## Waiting for a commit
148
132
 
149
133
  `execute()` is asynchronous: the receipt names a PENDING transition until the store folds it.
@@ -179,24 +163,12 @@ service; releasing a subscription never closes it — `context.planning().close(
179
163
 
180
164
  A Node client passes no opener and relies on the long poll.
181
165
 
182
- ## Optimistic writes and `head > seq`
183
-
184
- `seq` is the last transition folded into a card, `head` the highest allocated. `applyReceipt`
185
- (run by `execute` whenever stores are registered) raises the stored card's `head` to the new
186
- transition's `seq`, so `model.pending()` is true before any frame arrives; the commit's fold brings
187
- `seq` up to it. The mirror only ever grows `head`, so a list fetched before the append does not
188
- clear the marker. A model's writes default `expectSeq` to `record.head ?? record.seq`; a stale model
189
- gets `WorkcardConflict` — reload and retry.
190
-
191
166
  ## Gotchas
192
167
 
193
168
  - Mount a feed once per screen; read the store everywhere else.
194
- - Do not `replace()` the card store — it holds every kind. Use `syncCards` with a `where`.
195
169
  - A refusal crosses the hop as its class (`IllegalTransition`, `WorkcardConflict`,
196
170
  `FieldsInvalid`, a plugin's own class) — branch on `instanceof`, never on message text.
197
171
  - `CommitTimeout` is not a failure of the transition; re-wait or re-read the card.
198
- - The scope passed to `for()` changes nothing the server decides; it exists so code written against
199
- `PlanningFacade` reads the same on both sides.
200
172
 
201
173
  ## Depends On
202
174
 
@@ -209,4 +181,14 @@ gets `WorkcardConflict` — reload and retry.
209
181
  - `planning` — records, flows, the fold, the models
210
182
  - `server-planning` — the handlers this addresses, the executor, the commit hub
211
183
  - `client-job` — the same seed-then-fold shape for safe application-job views
212
- - `state` — `syncCards` is built on its criteria engine and `purge`
184
+ - `state` — `syncHelper.syncCards` is built on its criteria engine and `purge`
185
+
186
+ ## Parts
187
+
188
+ Load a part on top of this core when its job is in play.
189
+
190
+ | Part | Load it when |
191
+ |---|---|
192
+ | [validation](parts/validation.md) | Validating planning `fields` in a browser form under a strict CSP (`makePlanningFieldValidator`) |
193
+ | [mirror](parts/mirror.md) | Reading lists from the state mirror (`syncHelper`, `planningMirrorOf`), mounting `makePlanningFeed`, or relying on `head > seq` |
194
+ | [aux-mirrors](parts/aux-mirrors.md) | Assignee, team, comment or mention mirrors and feeds, or switching organization / signing out (`scopeKey`, `close()`) |
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: aux-mirrors
3
+ description: The assignee, team, comment and mention mirrors and resource feeds, and the authenticated scope lifecycle (scopeKey, close, lifecycle.run and lifecycle.mutate).
4
+ metadata:
5
+ purposes: state, auth
6
+ mentions: makePlanningResourceFeed, scopeKey, lifecycle.run, lifecycle.mutate, registerFeed, client:scope-changed, PlanningReply
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/client-planning — Auxiliary mirrors and the scope lifecycle
11
+
12
+ ## Auxiliary mirrors and authenticated scope lifecycle
13
+
14
+ With `resources: true` in the shared protocol tree, every remote facade exposes the complete
15
+ assignee/team/comment/mention API, including versioned writes, team membership and project
16
+ attachment. `appendPlanningStores` registers separate stock typed mirrors for all four resources.
17
+ `makePlanningResourceFeed` seeds and polls selected resource queries; use it with the stock stores
18
+ and `useStoreList` / `useStoreModel`. Auxiliary writes update the mirror only after success.
19
+ An active-assignee query (`retired: false`) includes an absent or null retirement flag. Local
20
+ mirror queries and feed reconciliation use `{ retired: { $ne: true } }`, matching the native
21
+ facade rather than strict equality to false. A filtered poll removes only its unchanged matching
22
+ snapshot rows, preserving unrelated retired identities needed by existing references.
23
+ Comments use trusted server authors; mentions are read-only derived cache commands.
24
+
25
+ Supply `PlanningClientOptions.scopeKey` from authenticated organization entity slug, profile/user
26
+ id, session id and authorization revision; return undefined when signed out. Before switching
27
+ organization or signing out, **await** `service.close()` before adopting the credential. It cancels
28
+ requests and commit waits, invalidates receipts (including already-settled receipts), stops every
29
+ registered feed, removes socket subscriptions, closes the carrier and rejects late socket openers.
30
+ A stale operation answers `client:scope-changed`; cancellation never rolls back an accepted server
31
+ write. A new subscription opens a fresh carrier after close.
32
+
33
+ Close drains admitted **local** mirror writes before clearing every stock store and schema cache.
34
+ New calls during an explicit close are refused, including hosts without `scopeKey`. Concurrent
35
+ closes share the boundary. If clearing fails, close rejects and fresh work stays blocked; retain
36
+ the old credential and retry close explicitly. An observed identity change also cancels old work
37
+ and queues clearing before the new identity's mirror writes. Reload schemas and create fresh feeds
38
+ after credential adoption. Feed query scope alone is insufficient for this lifecycle.
39
+
40
+ The service's `lifecycle` coordinates stock facades, receipts, feeds and schema writes. A custom
41
+ mirror integration captures an operation with `lifecycle.run`, performs remote reads through
42
+ `operation.wait` with `operation.signal`, then uses `lifecycle.mutate(operation, write)` for local
43
+ writes; never hold or nest that queue over a request, socket opener or another queued mutation.
44
+ `planningMirrorOf(stores)` accepts `{ lifecycle, operation }` as its optional last argument and
45
+ fetches missing frame records/links before entering the queue. Direct mirror/sync helpers without
46
+ that option are local primitives: their caller owns isolation and cleanup.
47
+
48
+ Planning protocols return `PlanningReply<T>` without storage organization identifiers. Stock
49
+ facades hydrate records, receipts, schema scopes and socket frames with `entityId: ''` only to
50
+ preserve the shared facade shape. That value is an advisory placeholder, never an organization
51
+ identity or a credential. Use `scopeKey` and the authenticated organization entity slug for
52
+ identity changes and access isolation; native server records keep their verified stable scope.
53
+
54
+ Resource feed reconciliation snapshots mirror versions before requesting a list. Only unchanged
55
+ snapshot rows absent from that response are removed, so a new local write or a newer version
56
+ survives an older in-flight poll. Stopped feeds cannot publish. Track custom feeds through
57
+ `registerFeed` when their lifecycle must end with the planning service. Card feeds use the same
58
+ identity fence and stop registration; component cleanup awaits `stop()` when it must observe a
59
+ completed boundary. Stop cancels unresolved seeds and drains admitted local changes without waiting
60
+ for the network; neither late callbacks nor authoritative deletion passes may publish afterward.
61
+ A schema write from an old generation cannot invalidate a newer generation's cached registry.
@@ -0,0 +1,67 @@
1
+ ---
2
+ name: mirror
3
+ description: The state mirror's one id space and fold rules, makePlanningFeed, and optimistic writes with head over seq.
4
+ metadata:
5
+ purposes: state, list
6
+ mentions: appendPlanningStores, planningMirrorOf, applyCommitEvent, applyReceipt, applyCards, syncHelper, syncCards, syncLinks, makePlanningFeed, PlanningFeed, useStoreList
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/client-planning — The mirror and the feed
11
+
12
+ ## The mirror — one id space
13
+
14
+ `appendPlanningStores` registers ONE card store for every kind. Projects, cards and specifications
15
+ share an id space on the server, so they share one store here: a store per kind lets a card list
16
+ that reloads drop the projects beside it.
17
+
18
+ That is also why a list reaches the store through **`syncHelper.syncCards(store, items, where)`,
19
+ never `replace()`**: every card given is written, every card matching `where` that the list does not
20
+ name is dropped, and everything outside `where` is left alone. An unchanged card is not rewritten, so a
21
+ periodic re-seed wakes no subscriber. `opts.keep` protects ids a commit wrote while the list was in
22
+ flight.
23
+
24
+ The fold rules, shared by `syncHelper.syncCards`, the mirror's `applyCards` and `applyCommitEvent`, and
25
+ `commits.wait`:
26
+
27
+ - an OLDER `seq` never overwrites a newer record — frames and list answers race;
28
+ - a committed `delete` removes the row and every link touching it; a project's delete also drops
29
+ the rows whose `parents` hold it (the server purged them in the same fold);
30
+ - a frame for an id the store never saw still writes a row;
31
+ - a `failed` commit leaves the card alone and records the reason in the commit store;
32
+ - a frame without `record` (a cross-process bus carries ids only) is re-read through the facade —
33
+ without one, only the commit row is written;
34
+ - a committed `link`/`unlink` re-lists the card's outgoing links; links written together with a
35
+ `create` arrive with the next link seed.
36
+
37
+ ## The feed
38
+
39
+ ```typescript
40
+ const feed = makePlanningFeed(context, {
41
+ query: { parent: shedId, type: 'shed:tool' },
42
+ filter: { project: shedId },
43
+ refresh: 15_000,
44
+ onChange: state => setState(state),
45
+ })
46
+ await feed.ready // never rejects — read feed.error
47
+ // …
48
+ await feed.stop() // stops folding; the shared socket stays open
49
+ ```
50
+
51
+ 1. Subscribes to commits FIRST (the socket, when an opener exists).
52
+ 2. Seeds: `cards.list` (unpaged unless the query pages) → `syncHelper.syncCards` over `where`
53
+ (`queryHelper.criteriaOf(query)` by default). A PAGED seed only writes — a page cannot say what
54
+ does not exist.
55
+ 3. Folds every frame with `planningMirrorOf(stores).applyCommitEvent`, and re-seeds every `refresh`
56
+ ms — the authoritative backstop to a socket that can drop frames.
57
+
58
+ `connected` reports whether a socket was open when the feed subscribed; `seeded` tells "nothing
59
+ there" from "not loaded yet". A React hook wraps the feed in an effect and stops it on unmount.
60
+
61
+ ## Optimistic writes and `head > seq`
62
+
63
+ `seq` is the last transition folded into a card, `head` the highest allocated. The mirror's
64
+ `applyReceipt` (run by `execute` whenever stores are registered) raises the stored card's `head` to
65
+ the new transition's `seq`, so `model.pending()` is true before any frame arrives; the commit's fold
66
+ brings `seq` up to it. The mirror only ever grows `head`, so a list fetched before the append does not
67
+ clear the marker. A model's `expectSeq` default and its `WorkcardConflict` are `planning` → Models.
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: validation
3
+ description: CSP-safe browser validation of a served field schema with makePlanningFieldValidator — the supported keywords, refusals and limits, and the external references behind them.
4
+ metadata:
5
+ purposes: form
6
+ mentions: makePlanningFieldValidator, validateFields, @cfworker/json-schema, JSON_SCHEMA_DRAFT_07, unsafe-eval, SchemaInvalid
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/client-planning — Browser field validation
11
+
12
+ ## Browser field validation under CSP
13
+
14
+ Bind `makePlanningFieldValidator(registry.fieldsSchema(type))` to the registry of the resolved
15
+ organization or project bundle — the type's own `fields` plus the application's base card fields
16
+ (`planning` → types part); with no base fields it IS `type.fields`. Passing `type.fields` alone
17
+ skips the base fields the server still enforces. Its `validateFields(fields)` returns `{ valid, errors }`; errors
18
+ carry decoded `path`, `keyword`, `schemaPath` and a bounded value-free `message`. Construct one JSON
19
+ fields object, validate it and send that same object. No coercion, defaults or property removal
20
+ occurs, and the schema is left unchanged. Preserve nested money objects and calendar date strings.
21
+ The native server validates every write again.
22
+
23
+ The validator interprets schemas with `@cfworker/json-schema`, with no `eval`, `new Function` or
24
+ network reference loading. Building, loading and merging a registry is safe under a strict
25
+ `script-src` — `loadSchemas()`, `definitions.registry()`/`bundle()`, `model()`, `makeSchemaRegistry(bundle)`,
26
+ `scopedRegistryOf` — because a page runs only the closed-form checks of the served `app` declarations
27
+ (`planning` → types part). Do not call `.validator()`, `.assigneeValidator()`, `appSchemaHelper.assertBag`,
28
+ `scopedSchemaHelper.assert*Schema` or `validateHelper.*` in a browser with strict `script-src`: those
29
+ remain native AJV compilers, the server's.
30
+ The Draft 7 meta-schema is DATA in `src/validation/consts.ts` (`JSON_SCHEMA_DRAFT_07`, pinned to ajv's
31
+ `refs/json-schema-draft-07.json` by `tests/draft7.spec.ts`), never an import of that file: Node ESM needs
32
+ `with { type: 'json' }` on it, and a bundler that also meets ajv's own attribute-less `require` of the
33
+ same file refuses the pair (Rollup: "import attributes for the same module are always consistent"),
34
+ which fails every target web build that mounts the planning client.
35
+ Static AJV standalone compilation is suitable only when the schema cannot change after building;
36
+ organization and project definitions require the runtime interpreter.
37
+
38
+ The supported contract is Draft 7's object/array/scalar, required, enum/const, ranges, string
39
+ length/pattern, dependencies, boolean, composition and conditional keywords; `$defs`, local/nested
40
+ `$id` references and AJV's `nullable` are also supported. `$ref` sibling constraints are enforced as
41
+ by native planning AJV. String formats use the same `ajv-formats` full-mode predicates, including
42
+ real calendar dates and timezone-aware times. `nullable` adds null to the declared type while
43
+ leaving enum and const constraints intact. All references must resolve inside the supplied field
44
+ schema; productive recursive schemas are supported, same-instance reference cycles are refused.
45
+ Plain JSON values and field names without dot or dollar characters are required.
46
+
47
+ Preparation throws `SchemaInvalid` for malformed schemas, unresolved references, unsupported
48
+ drafts/keywords/formats, numeric formats, `formatMinimum`/`formatMaximum` extensions and
49
+ `multipleOf`. The latter is refused because the interpreter's epsilon and native AJV's quotient
50
+ semantics disagree for fractional and very large numbers. Standard annotation keywords and `x-`
51
+ annotations do not add validation. Never discard a refused constraint, invent a substitute schema,
52
+ enable `unsafe-eval` or proceed with a ready form after preparation fails. Show the error and retain
53
+ disabled save controls. Validation reports at most 64 errors, with paths bounded to 512 characters.
54
+
55
+ ## External docs
56
+
57
+ - https://ajv.js.org/security.html#content-security-policy — runtime AJV requires unsafe-eval; standalone build compilation preserves strict CSP for static schemas.
58
+ - https://www.npmjs.com/package/@cfworker/json-schema/v/4.1.1 — the interpreter supports dynamic schemas without code generation; select an explicit vocabulary and check its known limitations (4.1.1).
59
+ - https://github.com/ajv-validator/ajv-formats#full-and-fast-validation-modes — full-mode predicates validate formats completely and are reusable through the plugin's `get` API (3.0.1).
60
+ - https://ajv.js.org/json-schema.html#nullable-openapi — nullable expands the declared type without adding null to enum or const.