@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,121 @@
1
+ // owlmeans-structure: allow-file the conformance fixtures are one declarative data set together with the plugin that serves it
2
+ import { CodeScope, CodeStyle, IntrinsicStatus, SpecificationFormat, TransitionAction, WorkcardKind, type AnyTypeSchema, type PlanningFacade, type PlanningPlugin, type StatusFlowSchema, type TransitionReceipt, type Workcard } from '@owlmeans/planning'
3
+ import { BOOK_TYPE, LIBRARY, PERIODICAL_TYPE } from './consts.js'
4
+
5
+ const flows: StatusFlowSchema[] = [
6
+ {
7
+ id: LIBRARY.circulation,
8
+ version: 1,
9
+ statuses: [
10
+ { key: 'shelved', intrinsic: IntrinsicStatus.Planned, initial: true },
11
+ { key: 'lent', intrinsic: IntrinsicStatus.InProgress },
12
+ { key: 'retired', intrinsic: IntrinsicStatus.Closed, terminal: true },
13
+ ],
14
+ transitions: [
15
+ { name: 'lend', from: ['shelved'], to: 'lent', explicit: true },
16
+ { name: 'return', from: ['lent'], to: 'shelved', explicit: true },
17
+ { name: 'retire', from: '*', to: 'retired' },
18
+ { name: 'restore', from: ['retired'], to: 'shelved' },
19
+ ],
20
+ },
21
+ {
22
+ id: LIBRARY.review,
23
+ version: 1,
24
+ statuses: [
25
+ { key: 'unreviewed', intrinsic: IntrinsicStatus.Planned, initial: true },
26
+ { key: 'reviewed', intrinsic: IntrinsicStatus.Closed },
27
+ ],
28
+ transitions: [{ name: 'review', from: ['unreviewed'], to: 'reviewed' }],
29
+ },
30
+ {
31
+ id: LIBRARY.branchLife,
32
+ version: 1,
33
+ statuses: [
34
+ { key: 'open', intrinsic: IntrinsicStatus.Planned, initial: true },
35
+ { key: 'active', intrinsic: IntrinsicStatus.InProgress },
36
+ ],
37
+ transitions: [{ name: 'activate', from: ['open'], to: 'active' }],
38
+ },
39
+ {
40
+ id: LIBRARY.pageLife,
41
+ version: 1,
42
+ statuses: [{ key: 'draft', intrinsic: IntrinsicStatus.Planned, initial: true }],
43
+ transitions: [],
44
+ },
45
+ ]
46
+
47
+ const types: AnyTypeSchema[] = [
48
+ {
49
+ type: LIBRARY.branch,
50
+ kind: WorkcardKind.Project,
51
+ version: 1,
52
+ fields: { type: 'object', additionalProperties: true },
53
+ flows: [LIBRARY.branchLife],
54
+ cardTypes: [LIBRARY.book, LIBRARY.periodical],
55
+ projectTypes: [LIBRARY.branch],
56
+ scopedCardTypes: true,
57
+ specifications: [
58
+ { category: 'charter', format: SpecificationFormat.Markdown },
59
+ { category: 'notes', format: SpecificationFormat.Markdown, multiple: true },
60
+ ],
61
+ code: { style: CodeStyle.Slug, uniqueWithin: CodeScope.Entity, mutable: true },
62
+ },
63
+ {
64
+ type: LIBRARY.room,
65
+ kind: WorkcardKind.Project,
66
+ version: 1,
67
+ fields: { type: 'object', additionalProperties: true },
68
+ flows: [LIBRARY.branchLife],
69
+ cardTypes: [LIBRARY.book],
70
+ specifications: [],
71
+ },
72
+ BOOK_TYPE,
73
+ PERIODICAL_TYPE,
74
+ {
75
+ type: LIBRARY.page,
76
+ kind: WorkcardKind.Specification,
77
+ version: 1,
78
+ fields: { type: 'object', additionalProperties: true },
79
+ flows: [LIBRARY.pageLife],
80
+ specifications: [],
81
+ },
82
+ ]
83
+
84
+ /** The types and flows every conformance case runs against — register it on the service under test. */
85
+ export const planningConformancePlugin: PlanningPlugin = Object.freeze({
86
+ name: 'planning-conformance',
87
+ order: 0,
88
+ schemas: { types, flows },
89
+ })
90
+
91
+ /**
92
+ * A clock that never repeats an instant — what a conformance service is booted with, so an
93
+ * `updatedSince` or an ordering by `at` is deterministic on a fast store.
94
+ */
95
+ export const conformanceClock = (): (() => string) => {
96
+ let last = 0
97
+ return () => {
98
+ last = Math.max(Date.now(), last + 1)
99
+ return new Date(last).toISOString()
100
+ }
101
+ }
102
+
103
+ export const createBranch = async (
104
+ planning: PlanningFacade, title: string = 'Riverside branch', extra: Record<string, unknown> = {}
105
+ ): Promise<Workcard> => (await planning.execute({
106
+ card: { kind: WorkcardKind.Project, type: LIBRARY.branch, title, ...extra },
107
+ action: TransitionAction.Create,
108
+ }, { wait: true })).card!
109
+
110
+ export const createBook = async (
111
+ planning: PlanningFacade, branch: string, title: string = 'A book', extra: Record<string, unknown> = {}
112
+ ): Promise<Workcard> => (await planning.execute({
113
+ card: { kind: WorkcardKind.Card, type: LIBRARY.book, parent: branch, title, ...extra },
114
+ action: TransitionAction.Create,
115
+ }, { wait: true })).card!
116
+
117
+ export const transit = async (
118
+ planning: PlanningFacade, card: string, transition: string, extra: { flow?: string } = {}
119
+ ): Promise<TransitionReceipt> => await planning.execute({
120
+ card, action: TransitionAction.Transit, transition, ...extra,
121
+ }, { wait: true })
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The planning store conformance suite — a framework-agnostic list of named cases every
3
+ * `PlanningStore` answers identically. No test runner is imported: a store's own spec file loops
4
+ * over {@link conformanceCasesFor} and hands each case a {@link ConformanceSubject}; a case throws
5
+ * `ConformanceFailure` (a plain `Error`) when an expectation does not hold.
6
+ */
7
+ export * from './assert.js'
8
+ export * from './cases.js'
9
+ export type * from './types.js'
10
+ export * from './fixtures.js'
11
+ export * from './consts.js'
@@ -0,0 +1,20 @@
1
+ import type { PlanningFacade, PlanningService, PlanningStore } from '@owlmeans/planning'
2
+
3
+ /** What a case runs against: a planning service over the store under test. */
4
+ export interface ConformanceSubject {
5
+ /** A service with `planningConformancePlugin` registered and a strictly increasing clock. */
6
+ service: PlanningService
7
+ /** The store under test — the service's default store. */
8
+ store: PlanningStore
9
+ /** A facade of one organization, acting as a signed-in person. */
10
+ facade: (entityId: string) => PlanningFacade
11
+ }
12
+
13
+ /** A capability a store may implement; a case that needs one is skipped for a store without it. */
14
+ export type ConformanceCapability = 'schemas'
15
+
16
+ export interface ConformanceCase {
17
+ name: string
18
+ needs?: ConformanceCapability[]
19
+ run: (subject: ConformanceSubject) => Promise<void>
20
+ }
package/src/consts.ts CHANGED
@@ -20,3 +20,9 @@ export const DEFAULT_COMMIT_MEMORY = 1000
20
20
  * ladder is what answers when it folded somewhere the hub does not.
21
21
  */
22
22
  export const COMMIT_POLL_LADDER: readonly number[] = Object.freeze([250, 1000, 2000])
23
+
24
+ /**
25
+ * How many resolved schema layers (organization, project) a service keeps. A layer is re-resolved
26
+ * whenever its organization's schema revision moves, so the cap bounds memory, never staleness.
27
+ */
28
+ export const DEFAULT_SCHEMA_VIEWS = 500
@@ -0,0 +1,250 @@
1
+ import { assertFlowSchema, assertOverridable, assertTypeSchema, flowInUse, isProject, PlanningSchemaKind, PlanningUnsupported, resolveScopedBundle, SchemaConflict, SchemaInUse, scopedRegistryOf, UnknownStatusFlow, UnknownWorkcardType, WorkcardNotFound, type PlanningDefinitions, type PlanningFacade, type PlanningSchemaRegistry, type SchemaDeclarations, type SchemaStore, type SchemaWriteOptions, type ScopedSchemaRecord, type ScopedSchemaRegistry, type StatusFlowSchema, type TransitionExecution, type Unsubscribe, type WorkcardTypeSchema } from '@owlmeans/planning'
2
+ import { DEFAULT_SCHEMA_VIEWS } from './consts.js'
3
+ import { actorOf } from './executor/changes.js'
4
+ import type { PlanningRuntime, SchemaViews, SchemaViewsOptions } from './types.js'
5
+ import type { Planned } from './types.local.js'
6
+
7
+ const isoNow = (): string => new Date().toISOString()
8
+
9
+ /**
10
+ * The resolved layers of one planning service, cached per (organization, project) and keyed by the
11
+ * organization's schema revision and the code registry's version — a write anywhere, in any
12
+ * process, moves the revision the next lookup reads, so a cached layer is never served stale. A
13
+ * store that can `watch` evicts an organization's layers as soon as it hears of a write.
14
+ */
15
+ export const makeSchemaViews = (opts: SchemaViewsOptions): SchemaViews => {
16
+ const limit = opts.limit ?? DEFAULT_SCHEMA_VIEWS
17
+ const cache = new Map<string, { revision: number, version: number, view: ScopedSchemaRegistry }>()
18
+ let watching: { store: SchemaStore, release: Unsubscribe } | undefined
19
+
20
+ const keyOf = (entityId: string, project?: string): string => `${entityId}\u0000${project ?? ''}`
21
+
22
+ const evict = (entityId: string): void => {
23
+ const prefix = `${entityId}\u0000`
24
+ for (const key of [...cache.keys()]) {
25
+ if (key.startsWith(prefix)) {
26
+ cache.delete(key)
27
+ }
28
+ }
29
+ }
30
+
31
+ const scoped: SchemaViews['scoped'] = async (entityId, project) => {
32
+ const store = opts.port()
33
+ if (store == null) {
34
+ throw new PlanningUnsupported('schemas')
35
+ }
36
+ if (store.watch != null && watching?.store !== store) {
37
+ watching?.release()
38
+ watching = { store, release: store.watch(evict) }
39
+ }
40
+
41
+ const revision = await store.revision(entityId)
42
+ const version = opts.version()
43
+ const key = keyOf(entityId, project)
44
+ const hit = cache.get(key)
45
+ if (hit != null && hit.revision === revision && hit.version === version) {
46
+ cache.delete(key)
47
+ cache.set(key, hit)
48
+ return hit.view
49
+ }
50
+
51
+ const records = [
52
+ ...await store.list({ entityId, project: null }),
53
+ ...(project != null ? await store.list({ entityId, project }) : []),
54
+ ]
55
+ const view = scopedRegistryOf(resolveScopedBundle(opts.code().bundle(), records, { entityId, project, revision }))
56
+ cache.delete(key)
57
+ cache.set(key, { revision, version, view })
58
+ while (cache.size > limit) {
59
+ const oldest = cache.keys().next().value
60
+ if (oldest == null) {
61
+ break
62
+ }
63
+ cache.delete(oldest)
64
+ }
65
+
66
+ return view
67
+ }
68
+
69
+ return {
70
+ of: async (entityId, project) => opts.port() == null ? opts.code() : await scoped(entityId, project),
71
+ scoped,
72
+ }
73
+ }
74
+
75
+ const planOf = (declarations: SchemaDeclarations): Planned[] => [
76
+ ...(declarations.flows ?? []).map(flow => ({ kind: PlanningSchemaKind.Flow, key: flow.id, definition: flow })),
77
+ ...(declarations.types ?? []).map(type => ({ kind: PlanningSchemaKind.Type, key: type.type, definition: type })),
78
+ ]
79
+
80
+ const layerKey = (kind: PlanningSchemaKind, key: string): string => `${kind}:${key}`
81
+
82
+ /**
83
+ * The data-defined schema surface of one scoped facade. Every write checks, before anything is
84
+ * stored: the project (a project card this scope can see), the seal of the code registry, the
85
+ * declaration's closed-form checks (a type's flows must resolve in its layer, counting the flows
86
+ * written beside it) — and then writes through the port's compare-and-set.
87
+ */
88
+ export const makeDefinitions = (runtime: PlanningRuntime, facade: PlanningFacade): PlanningDefinitions => {
89
+ const scope = facade.scope
90
+ const entityId = scope.entityId
91
+ const now = runtime.options.now ?? isoNow
92
+
93
+ const port = (): SchemaStore => {
94
+ const store = runtime.schemaStore()
95
+ if (store == null) {
96
+ throw new PlanningUnsupported('schemas')
97
+ }
98
+ return store
99
+ }
100
+
101
+ const visible = (project: string): boolean => scope.projects == null || scope.projects.includes(project)
102
+
103
+ /** @throws {WorkcardNotFound} for anything but a project card this scope can see */
104
+ const assertProject = async (project?: string): Promise<void> => {
105
+ if (project == null) {
106
+ return
107
+ }
108
+ const card = await facade.cards.load(project)
109
+ if (card == null || !isProject(card)) {
110
+ throw new WorkcardNotFound(project)
111
+ }
112
+ }
113
+
114
+ const layerOf = async (project?: string): Promise<Map<string, ScopedSchemaRecord>> =>
115
+ new Map((await port().list({ entityId, project: project ?? null }))
116
+ .map(record => [layerKey(record.kind, record.key), record]))
117
+
118
+ /** Seal and closed-form checks for everything in the plan — nothing is written when one fails. */
119
+ const validate = async (plan: Planned[], project?: string): Promise<void> => {
120
+ const code = runtime.service().schemas
121
+ const layer = await runtime.scopedSchemas(entityId, project)
122
+ const pending = new Map(plan.filter(entry => entry.kind === PlanningSchemaKind.Flow)
123
+ .map(entry => [entry.key, entry.definition as StatusFlowSchema]))
124
+ const flows: Pick<PlanningSchemaRegistry, 'flow'> = { flow: id => pending.get(id) ?? layer.flow(id) }
125
+
126
+ for (const entry of plan) {
127
+ assertOverridable(code, entry.kind, entry.key)
128
+ if (entry.kind === PlanningSchemaKind.Flow) {
129
+ assertFlowSchema(entry.definition as StatusFlowSchema)
130
+ } else {
131
+ assertTypeSchema(entry.definition as WorkcardTypeSchema, flows)
132
+ }
133
+ }
134
+ }
135
+
136
+ const recordOf = (
137
+ entry: Planned, version: number, project: string | undefined, current?: ScopedSchemaRecord, retired?: boolean
138
+ ): ScopedSchemaRecord => {
139
+ const at = now()
140
+ const by = actorOf(scope, {} as TransitionExecution)
141
+ return Object.fromEntries(Object.entries({
142
+ entityId,
143
+ project,
144
+ kind: entry.kind,
145
+ key: entry.key,
146
+ version,
147
+ definition: { ...structuredClone(entry.definition), version },
148
+ retired: retired === true ? true : undefined,
149
+ createdAt: current?.createdAt ?? at,
150
+ updatedAt: current != null ? at : undefined,
151
+ by: Object.keys(by).length > 0 ? by : undefined,
152
+ }).filter(([, value]) => value !== undefined)) as unknown as ScopedSchemaRecord
153
+ }
154
+
155
+ const write = async (
156
+ plan: Planned[], opts: SchemaWriteOptions | undefined, versionOf: (entry: Planned, current?: ScopedSchemaRecord) => number | null
157
+ ): Promise<ScopedSchemaRecord[]> => {
158
+ await assertProject(opts?.project)
159
+ const layer = await layerOf(opts?.project)
160
+ const todo = plan
161
+ .map(entry => ({ entry, current: layer.get(layerKey(entry.kind, entry.key)) }))
162
+ .map(item => ({ ...item, version: versionOf(item.entry, item.current) }))
163
+ .filter((item): item is typeof item & { version: number } => item.version != null)
164
+ await validate(todo.map(item => item.entry), opts?.project)
165
+
166
+ const written: ScopedSchemaRecord[] = []
167
+ for (const item of todo) {
168
+ written.push(await port().put(recordOf(item.entry, item.version, opts?.project, item.current)))
169
+ }
170
+ return written
171
+ }
172
+
173
+ const put = async (entry: Planned, opts?: SchemaWriteOptions): Promise<ScopedSchemaRecord> =>
174
+ (await write([entry], opts, () => entry.definition.version))[0]
175
+
176
+ const definitions: PlanningDefinitions = {
177
+ bundle: async project => {
178
+ await assertProject(project)
179
+ return (await runtime.scopedSchemas(entityId, project)).bundle()
180
+ },
181
+
182
+ registry: async project => {
183
+ await assertProject(project)
184
+ return await runtime.scopedSchemas(entityId, project)
185
+ },
186
+
187
+ records: async opts => {
188
+ if (typeof opts?.project === 'string' && !visible(opts.project)) {
189
+ return []
190
+ }
191
+ const records = await port().list(Object.fromEntries(Object.entries({
192
+ entityId, project: opts?.project, kind: opts?.kind, retired: opts?.retired,
193
+ }).filter(([, value]) => value !== undefined)) as { entityId: string })
194
+ return records.filter(record => record.project == null || visible(record.project))
195
+ },
196
+
197
+ putType: async (type, opts) => await put({ kind: PlanningSchemaKind.Type, key: type.type, definition: type }, opts),
198
+
199
+ putFlow: async (flow, opts) => await put({ kind: PlanningSchemaKind.Flow, key: flow.id, definition: flow }, opts),
200
+
201
+ define: async (declarations, opts) =>
202
+ await write(planOf(declarations), opts, (_, current) => (current?.version ?? 0) + 1),
203
+
204
+ seed: async (declarations, opts) => {
205
+ const written: ScopedSchemaRecord[] = []
206
+ try {
207
+ written.push(...await write(planOf(declarations), opts, (_, current) => current == null ? 1 : null))
208
+ } catch (error) {
209
+ if (!(error instanceof SchemaConflict)) {
210
+ throw error
211
+ }
212
+ // A concurrent seed wrote a key first; what is left is still only what the layer lacks.
213
+ written.push(...await write(planOf(declarations), opts, (_, current) => current == null ? 1 : null))
214
+ }
215
+ return written
216
+ },
217
+
218
+ retire: async (kind, key, opts) => {
219
+ await assertProject(opts?.project)
220
+ const current = (await layerOf(opts?.project)).get(layerKey(kind, key))
221
+ if (current == null) {
222
+ throw kind === PlanningSchemaKind.Type ? new UnknownWorkcardType(key) : new UnknownStatusFlow(key)
223
+ }
224
+ if (current.retired === true) {
225
+ return current
226
+ }
227
+ const retiring = recordOf({ kind, key, definition: current.definition }, current.version + 1, opts?.project, current, true)
228
+
229
+ if (kind === PlanningSchemaKind.Flow) {
230
+ const code = runtime.service().schemas.bundle()
231
+ const all = (await port().list({ entityId }))
232
+ .map(record => record.kind === kind && record.key === key && record.project === current.project ? retiring : record)
233
+ // The organization's layer reaches every project; a project's reaches itself alone.
234
+ const projects = opts?.project != null
235
+ ? [opts.project]
236
+ : [undefined, ...new Set(all.map(record => record.project).filter((id): id is string => id != null))]
237
+ for (const project of projects) {
238
+ const user = flowInUse(resolveScopedBundle(code, all, { entityId, project }), key)
239
+ if (user != null) {
240
+ throw new SchemaInUse(`flow:${key}:type:${user}${project != null ? `:project:${project}` : ''}`)
241
+ }
242
+ }
243
+ }
244
+
245
+ return await port().put(retiring)
246
+ },
247
+ }
248
+
249
+ return definitions
250
+ }
@@ -1,9 +1,5 @@
1
- import { CommitState, computeChanges, isEmptyChange, TransitionAction } from '@owlmeans/planning'
2
- import type {
3
- ChangeSet, PlanningSchemaRegistry, PlanningScope, RelationshipDraft, Transition, TransitionActor,
4
- TransitionExecution, WorkcardDraft,
5
- } from '@owlmeans/planning'
6
- import type { Resolved } from './resolve.js'
1
+ import { CommitState, computeChanges, isEmptyChange, TransitionAction, type ChangeSet, type PlanningSchemaRegistry, type PlanningScope, type RelationshipDraft, type Transition, type TransitionActor, type TransitionExecution, type WorkcardDraft } from '@owlmeans/planning'
2
+ import type { Resolved } from './types.js'
7
3
 
8
4
  const clean = <T extends object>(record: T): T =>
9
5
  Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
@@ -1,13 +1,7 @@
1
- import {
2
- CODE_MINT_ATTEMPTS, CodeScope, CodeStyle, CodeTaken, mintCode, TransitionAction,
3
- } from '@owlmeans/planning'
4
- import type {
5
- CodePolicy, PlanningExecContext, PlanningPlugin, PlanningScope, TransitionExecution, WorkcardDraft,
6
- } from '@owlmeans/planning'
1
+ import { CODE_MINT_ATTEMPTS, CodeScope, CodeStyle, CodeTaken, mintCode, TransitionAction, type CodePolicy, type PlanningExecContext, type PlanningPlugin, type PlanningScope, type TransitionExecution, type WorkcardDraft, type Workcard } from '@owlmeans/planning'
7
2
  import type { Criteria } from '@owlmeans/resource'
8
- import type { Workcard } from '@owlmeans/planning'
9
- import type { PluginRegistry } from '../registry.js'
10
- import type { Resolved } from './resolve.js'
3
+ import type { PluginRegistry } from '../types.js'
4
+ import type { Resolved } from './types.js'
11
5
 
12
6
  /** The criteria a code must be unique within — the policy's scope, `parent` when there is none. */
13
7
  const scopeCriteria = (
@@ -0,0 +1,8 @@
1
+ import { TransitionAction } from '@owlmeans/planning'
2
+
3
+ /** The field a create's draft writes once and nothing moves afterwards. */
4
+ export const CREATOR = 'createdBy'
5
+
6
+ export const TRIMMED = ['title', 'description', 'code'] as const
7
+
8
+ export const ACTIONS = new Set<string>(Object.values(TransitionAction))
@@ -0,0 +1,51 @@
1
+ import { PlanningError, TransitionAction, type PlanningScope, type TransitionExecution } from '@owlmeans/planning'
2
+ import { CREATOR } from './consts.local.js'
3
+
4
+ const named = (value?: string | null): value is string => value != null && value !== ''
5
+
6
+ /**
7
+ * The subject a create made under `scope` is stamped with as its `createdBy`: the scope's
8
+ * `profileId`, else its `userId`, else the same two of its `actor` — the authenticated subject an
9
+ * HTTP scope carries, in the same format. `undefined` for a scope that names nobody (a service or a
10
+ * system scope).
11
+ */
12
+ export const creatorOf = (scope: PlanningScope): string | undefined =>
13
+ [scope.profileId, scope.userId, scope.actor?.profileId, scope.actor?.userId].find(named)
14
+
15
+ /**
16
+ * A create's draft with `createdBy` defaulted to {@link creatorOf} the scope. A `createdBy` the
17
+ * caller supplied wins; a scope that names nobody leaves the draft as it is; anything but a create
18
+ * is returned untouched. Never mutates `exec`.
19
+ *
20
+ * The executor applies it to every execution, so an in-process facade create and a wire create
21
+ * stamp the same value.
22
+ */
23
+ export const withCreator = (exec: TransitionExecution, scope: PlanningScope): TransitionExecution => {
24
+ if (exec.action !== TransitionAction.Create || exec.card == null || typeof exec.card !== 'object') {
25
+ return exec
26
+ }
27
+ if (named(exec.card.createdBy)) {
28
+ return exec
29
+ }
30
+ const creator = creatorOf(scope)
31
+
32
+ return creator == null ? exec : { ...exec, card: { ...exec.card, createdBy: creator } }
33
+ }
34
+
35
+ /**
36
+ * Refuse an execution that names `createdBy` in `changes` or `unset`, whatever the action — a
37
+ * create included, whose owner is its draft's. After the create it never moves, so an ownership
38
+ * check (`card.createdBy === profileId`) cannot be bypassed by anyone allowed to update the card.
39
+ *
40
+ * The executor runs it with the immutables of step 6, after the `before` chain, so a wire body, an
41
+ * in-process caller and a plugin are refused alike. It is the executor's own check, whatever
42
+ * `@owlmeans/planning` build `assertMutable` comes from.
43
+ *
44
+ * @throws {PlanningError} `planning:immutable:createdBy`
45
+ */
46
+ export const assertCreatorFixed = (exec: TransitionExecution): void => {
47
+ if ((exec.changes as Record<string, unknown> | undefined)?.[CREATOR] !== undefined
48
+ || (exec.unset ?? []).some(path => path.split('.')[0] === CREATOR)) {
49
+ throw new PlanningError(`immutable:${CREATOR}`)
50
+ }
51
+ }