@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
@@ -0,0 +1,93 @@
1
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
2
+ import { planningReplyHelper, PlanningUnsupported, SchemaWriteMode, scopedSchemaHelper } from '@owlmeans/planning'
3
+ import type {
4
+ PlanningProtocols, SchemaDefineReply, SchemaDefineRequest, ScopedSchemaBundle, ScopedSchemaRegistry,
5
+ } from '@owlmeans/planning'
6
+ import type { RemoteDefinitions, RemoteDefinitionsOptions } from './types.js'
7
+ import { makePlanningClientLifecycle } from './lifecycle.js'
8
+
9
+ const clean = <T extends object>(record: T): T =>
10
+ Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
11
+
12
+ /**
13
+ * Data-defined types and flows over a tree declared with `definitions: true`: a layer's registry is
14
+ * read through `schema.list` (with its `project`) and cached per project; every write goes through
15
+ * `schema.define` and drops the whole cache — an organization-wide write reaches every project's
16
+ * layer. The records of a layer are the server's alone (`records` refuses).
17
+ *
18
+ * @throws {PlanningUnsupported} for a tree declared without `definitions`
19
+ */
20
+ export const makeRemoteDefinitions = <C extends BasicConfig, T extends BasicContext<C>>(
21
+ context: T, protocols: PlanningProtocols, opts: RemoteDefinitionsOptions = {}
22
+ ): RemoteDefinitions => {
23
+ const lifecycle = opts.lifecycle ?? makePlanningClientLifecycle({ scopeKey: opts.scopeKey })
24
+ const define = protocols.schema.define
25
+ if (define == null) {
26
+ throw new PlanningUnsupported('client:definitions')
27
+ }
28
+ const cache = new Map<string, Promise<ScopedSchemaRegistry>>()
29
+
30
+ const invalidate = (): void => {
31
+ cache.clear()
32
+ opts.onWrite?.()
33
+ }
34
+
35
+ const registry = (project?: string): Promise<ScopedSchemaRegistry> => {
36
+ const identity = lifecycle.key()
37
+ const key = `${identity}\u0000${project ?? ''}`
38
+ let entry = cache.get(key)
39
+ if (entry == null) {
40
+ const loading = lifecycle.run(async operation => {
41
+ const bundle = planningReplyHelper.hydrate<ScopedSchemaBundle>(await operation.wait(context.entrypoint(protocols.schema.list).call({ query: project != null ? { project } : {}, timeout: opts.timeout, signal: operation.signal })))
42
+ return scopedSchemaHelper.scopedRegistryOf(bundle)
43
+ })
44
+ entry = loading
45
+ cache.set(key, loading)
46
+ // A failed read is forgotten, so the next caller asks again instead of inheriting the error.
47
+ loading.catch(() => {
48
+ if (cache.get(key) === loading) {
49
+ cache.delete(key)
50
+ }
51
+ })
52
+ }
53
+ return entry
54
+ }
55
+
56
+ const write = async (request: SchemaDefineRequest): Promise<SchemaDefineReply> => lifecycle.run(async operation => {
57
+ try {
58
+ return planningReplyHelper.hydrate<SchemaDefineReply>(await operation.wait(context.entrypoint(define).call({ body: clean(request), timeout: opts.timeout, signal: operation.signal })))
59
+ } finally {
60
+ if (operation.active()) invalidate()
61
+ }
62
+ })
63
+
64
+ return {
65
+ bundle: async project => (await registry(project)).bundle(),
66
+
67
+ registry,
68
+
69
+ records: async () => {
70
+ throw new PlanningUnsupported('client:definitions:records')
71
+ },
72
+
73
+ putAssigneeType: async schema => (await write({ assigneeTypes: [schema], mode: SchemaWriteMode.Put })).records[0],
74
+ putType: async (type, layer) =>
75
+ (await write({ project: layer?.project, mode: SchemaWriteMode.Put, types: [type] })).records[0],
76
+
77
+ putFlow: async (flow, layer) =>
78
+ (await write({ project: layer?.project, mode: SchemaWriteMode.Put, flows: [flow] })).records[0],
79
+
80
+ define: async (declarations, layer) =>
81
+ (await write({ project: layer?.project, mode: SchemaWriteMode.Define, ...declarations })).records,
82
+
83
+ seed: async (declarations, layer) =>
84
+ (await write({ project: layer?.project, mode: SchemaWriteMode.Seed, ...declarations })).records,
85
+
86
+ retire: async (kind, key, layer) => {
87
+ const { records } = await write({ project: layer?.project, retire: [{ kind, key }] })
88
+ return records[records.length - 1]
89
+ },
90
+
91
+ invalidate,
92
+ }
93
+ }
@@ -0,0 +1,35 @@
1
+ import type { CommitEvent, PlanningFacade, TransitionReceiptView, Workcard } from '@owlmeans/planning'
2
+ import type { PlanningClientLifecycle, PlanningOperation } from '../lifecycle/types.js'
3
+
4
+ export interface PlanningMirrorOptions {
5
+ lifecycle: PlanningClientLifecycle
6
+ operation: PlanningOperation
7
+ }
8
+
9
+ /** What one set of mirror stores learns from the server: commit frames, receipts and cards read. */
10
+ export interface PlanningMirror {
11
+ /**
12
+ * Fold one commit frame into the mirror.
13
+ *
14
+ * - An OLDER `seq` never overwrites a newer record — frames and list answers race.
15
+ * - A committed `delete` REMOVES the row and every link touching it (and, for a project, the rows
16
+ * under it — the server purged them in the same fold).
17
+ * - A frame for an id the store never saw still writes a row: a card created in another tab
18
+ * appears here without waiting for the next list.
19
+ * - A `failed` commit leaves the card alone; the reason is recorded in the commit store.
20
+ *
21
+ * A frame from a cross-process bus carries ids only. When `record` is absent the card is re-read
22
+ * through `facade`; without one there is nothing to write but the commit itself.
23
+ */
24
+ applyCommitEvent: (event: CommitEvent, facade?: PlanningFacade, options?: PlanningMirrorOptions) => Promise<void>
25
+ /**
26
+ * Mark a receipt in the mirror the moment the server answers it.
27
+ *
28
+ * A pending transition raises the card's `head` to its `seq`, so `head > seq` — the model's
29
+ * `pending()` — is true before any frame arrives. A receipt that already carries the committed
30
+ * card writes it; a committed delete drops the row.
31
+ */
32
+ applyReceipt: (receipt: TransitionReceiptView, options?: PlanningMirrorOptions) => Promise<void>
33
+ /** Write cards the caller read on its own, under the same newer-fold-wins rule as a commit. */
34
+ applyCards: (cards: Workcard[], options?: PlanningMirrorOptions) => Promise<void>
35
+ }
package/src/events.ts CHANGED
@@ -1,121 +1,131 @@
1
- import { CommitState, TransitionAction, WorkcardKind } from '@owlmeans/planning'
2
- import type { CommitEvent, PlanningFacade, TransitionReceiptView, Workcard } from '@owlmeans/planning'
3
- import { syncLinks } from './stores.js'
1
+ import { memoHelper } from '@owlmeans/context'
2
+ import { CommitState, TransitionAction, WorkcardKind, type CommitEvent, type PlanningFacade, type TransitionReceiptView, type Workcard } from '@owlmeans/planning'
3
+ import type { PlanningMirror, PlanningMirrorOptions } from './events/types.js'
4
+ import { syncHelper } from './sync.js'
4
5
  import type { PlanningStores } from './types.js'
5
- import { dropCard, putCard, putCommit } from './utils/record.js'
6
+ import { recordUtils } from './utils/record.js'
6
7
 
7
- /**
8
- * Fold one commit frame into the mirror.
9
- *
10
- * - An OLDER `seq` never overwrites a newer record — frames and list answers race.
11
- * - A committed `delete` REMOVES the row and every link touching it (and, for a project, the rows
12
- * under it — the server purged them in the same fold).
13
- * - A frame for an id the store never saw still writes a row: a card created in another tab
14
- * appears here without waiting for the next list.
15
- * - A `failed` commit leaves the card alone; the reason is recorded in the commit store.
16
- *
17
- * A frame from a cross-process bus carries ids only. When `record` is absent the card is re-read
18
- * through `facade`; without one there is nothing to write but the commit itself.
19
- */
20
- export const applyCommitEvent = async (
21
- stores: PlanningStores, event: CommitEvent, facade?: PlanningFacade
22
- ): Promise<void> => {
23
- const known = await stores.cards.load(event.card)
8
+ export const makePlanningMirror = (stores: PlanningStores): PlanningMirror => {
9
+ const local = async (options: PlanningMirrorOptions | undefined, work: () => Promise<void>): Promise<void> =>
10
+ options == null ? await work() : await options.lifecycle.mutate(options.operation, work)
11
+ const read = async <T>(options: PlanningMirrorOptions | undefined, pending: Promise<T>): Promise<T> =>
12
+ options == null ? await pending : await options.operation.wait(pending)
13
+ const applyCommitEvent = async (event: CommitEvent, facade?: PlanningFacade, options?: PlanningMirrorOptions): Promise<void> => {
14
+ options?.operation.check()
15
+ // Resolve network references before entering the local mutation queue. Close must never
16
+ // wait for a lost card/link read, and sequence comparisons happen against the latest mirror.
17
+ const settled = event.state === CommitState.Committed && event.action !== TransitionAction.Delete
18
+ const record = event.record !== undefined ? event.record
19
+ : settled && facade != null ? await read(options, facade.cards.load(event.card)) : undefined
20
+ const links = settled && facade != null && (event.action === TransitionAction.Link || event.action === TransitionAction.Unlink)
21
+ ? await read(options, facade.relationships.list({ from: event.card, size: 0 })) : undefined
22
+ await local(options, async () => {
23
+ const known = await stores.cards.load(event.card)
24
24
 
25
- await putCommit(stores.commits, {
26
- id: event.transition,
27
- card: event.card,
28
- entityId: event.entityId,
29
- project: event.project,
30
- kind: event.kind,
31
- type: event.type,
32
- seq: event.seq,
33
- action: event.action,
34
- state: event.state,
35
- at: event.at,
36
- error: event.error,
37
- })
25
+ await recordUtils.putCommit(stores.commits, {
26
+ id: event.transition,
27
+ card: event.card,
28
+ entityId: event.entityId,
29
+ project: event.project,
30
+ kind: event.kind,
31
+ type: event.type,
32
+ seq: event.seq,
33
+ action: event.action,
34
+ state: event.state,
35
+ at: event.at,
36
+ error: event.error,
37
+ })
38
38
 
39
- if (event.state === CommitState.Failed) {
40
- return
41
- }
42
- if (event.state === CommitState.Pending) {
43
- if (known != null && (known.head ?? known.seq) < event.seq) {
44
- await stores.cards.save({ ...known, head: event.seq })
45
- }
46
- return
47
- }
48
- if (known != null && known.seq > event.seq) {
49
- return
50
- }
39
+ if (event.state === CommitState.Failed) {
40
+ return
41
+ }
42
+ if (event.state === CommitState.Pending) {
43
+ if (known != null && (known.head ?? known.seq) < event.seq) {
44
+ await stores.cards.save({ ...known, head: event.seq })
45
+ }
46
+ return
47
+ }
48
+ if (known != null && known.seq > event.seq) {
49
+ return
50
+ }
51
51
 
52
- if (event.action === TransitionAction.Delete) {
53
- await dropCard(stores, event.card, event.kind === WorkcardKind.Project)
54
- return
55
- }
52
+ if (event.action === TransitionAction.Delete) {
53
+ await recordUtils.dropCard(stores, event.card, event.kind === WorkcardKind.Project)
54
+ return
55
+ }
56
56
 
57
- const record: Workcard | null | undefined = event.record !== undefined
58
- ? event.record
59
- : facade != null ? await facade.cards.load(event.card) : undefined
57
+ if (record === null) {
58
+ // Committed, and gone by the time it was read: a later delete won the race.
59
+ await recordUtils.dropCard(stores, event.card, event.kind === WorkcardKind.Project)
60
+ } else if (record !== undefined) {
61
+ await recordUtils.putCard(stores.cards, record)
62
+ }
60
63
 
61
- if (record === null) {
62
- // Committed, and gone by the time it was read: a later delete won the race.
63
- await dropCard(stores, event.card, event.kind === WorkcardKind.Project)
64
- } else if (record !== undefined) {
65
- await putCard(stores.cards, record)
64
+ if (links != null) {
65
+ await syncHelper.syncLinks(stores.links, links.items, { from: event.card })
66
+ }
67
+ })
66
68
  }
67
69
 
68
- if (facade != null && (event.action === TransitionAction.Link || event.action === TransitionAction.Unlink)) {
69
- const links = await facade.relationships.list({ from: event.card, size: 0 })
70
- await syncLinks(stores.links, links.items, { from: event.card })
71
- }
72
- }
70
+ const applyReceipt = async (receipt: TransitionReceiptView, options?: PlanningMirrorOptions): Promise<void> => local(options, async () => {
71
+ const { transition } = receipt
72
+ if (transition.id != null) {
73
+ await recordUtils.putCommit(stores.commits, {
74
+ id: transition.id,
75
+ card: transition.card,
76
+ entityId: transition.entityId,
77
+ project: transition.project,
78
+ kind: transition.kind,
79
+ type: transition.type,
80
+ seq: transition.seq,
81
+ action: transition.action,
82
+ state: transition.commit.state,
83
+ at: transition.commit.at,
84
+ error: transition.commit.error,
85
+ })
86
+ }
87
+ if (transition.commit.state === CommitState.Failed) {
88
+ return
89
+ }
90
+ if (transition.commit.state === CommitState.Committed && transition.action === TransitionAction.Delete) {
91
+ await recordUtils.dropCard(stores, transition.card, transition.kind === WorkcardKind.Project)
92
+ return
93
+ }
94
+ if (receipt.card != null) {
95
+ await recordUtils.putCard(stores.cards, receipt.card)
96
+ return
97
+ }
73
98
 
74
- /**
75
- * Mark a receipt in the mirror the moment the server answers it.
76
- *
77
- * A pending transition raises the card's `head` to its `seq`, so `head > seq` — the model's
78
- * `pending()` — is true before any frame arrives. A receipt that already carries the committed
79
- * card writes it; a committed delete drops the row.
80
- */
81
- export const applyReceipt = async (stores: PlanningStores, receipt: TransitionReceiptView): Promise<void> => {
82
- const { transition } = receipt
83
- if (transition.id != null) {
84
- await putCommit(stores.commits, {
85
- id: transition.id,
86
- card: transition.card,
87
- entityId: transition.entityId,
88
- project: transition.project,
89
- kind: transition.kind,
90
- type: transition.type,
91
- seq: transition.seq,
92
- action: transition.action,
93
- state: transition.commit.state,
94
- at: transition.commit.at,
95
- error: transition.commit.error,
96
- })
97
- }
98
- if (transition.commit.state === CommitState.Failed) {
99
- return
100
- }
101
- if (transition.commit.state === CommitState.Committed && transition.action === TransitionAction.Delete) {
102
- await dropCard(stores, transition.card, transition.kind === WorkcardKind.Project)
103
- return
104
- }
105
- if (receipt.card != null) {
106
- await putCard(stores.cards, receipt.card)
107
- return
108
- }
99
+ const known = await stores.cards.load(transition.card)
100
+ if (known != null && (known.head ?? known.seq) < transition.seq) {
101
+ await stores.cards.save({ ...known, head: transition.seq })
102
+ }
103
+ })
109
104
 
110
- const known = await stores.cards.load(transition.card)
111
- if (known != null && (known.head ?? known.seq) < transition.seq) {
112
- await stores.cards.save({ ...known, head: transition.seq })
113
- }
114
- }
105
+ const applyCards = async (cards: Workcard[], options?: PlanningMirrorOptions): Promise<void> => local(options, async () => {
106
+ for (const card of cards) {
107
+ await recordUtils.putCard(stores.cards, card)
108
+ }
109
+ })
115
110
 
116
- /** Write cards the caller read on its own, under the same newer-fold-wins rule as a commit. */
117
- export const applyCards = async (stores: Pick<PlanningStores, 'cards'>, cards: Workcard[]): Promise<void> => {
118
- for (const card of cards) {
119
- await putCard(stores.cards, card)
120
- }
111
+ return { applyCommitEvent, applyReceipt, applyCards }
121
112
  }
113
+
114
+ /** The mirror of one set of stores — one per stores object. */
115
+ export const planningMirrorOf = memoHelper.oncePer(makePlanningMirror)
116
+
117
+ /** @deprecated compat:factory-refactor — use `planningMirrorOf(stores).applyCommitEvent(…)` */
118
+ export const applyCommitEvent = async (
119
+ stores: PlanningStores, event: CommitEvent, facade?: PlanningFacade
120
+ ): Promise<void> => await planningMirrorOf(stores).applyCommitEvent(event, facade)
121
+
122
+ /** @deprecated compat:factory-refactor — use `planningMirrorOf(stores).applyReceipt(…)` */
123
+ export const applyReceipt = async (stores: PlanningStores, receipt: TransitionReceiptView): Promise<void> =>
124
+ await planningMirrorOf(stores).applyReceipt(receipt)
125
+
126
+ /**
127
+ * @deprecated compat:factory-refactor — use `planningMirrorOf(stores).applyCards(…)`. Only `cards` is
128
+ * read, so a narrower stores object still works here.
129
+ */
130
+ export const applyCards = async (stores: Pick<PlanningStores, 'cards'>, cards: Workcard[]): Promise<void> =>
131
+ await planningMirrorOf(stores as PlanningStores).applyCards(cards)
package/src/facade.ts CHANGED
@@ -1,14 +1,16 @@
1
+ import type { ListResult } from '@owlmeans/resource'
1
2
  import type { BasicConfig, BasicContext } from '@owlmeans/context'
2
3
  import {
3
- CommitFailed, CommitState, DEFAULT_COMMIT_TIMEOUT, PlanningError, PlanningUnsupported, WorkcardNotFound,
4
- encodeRelationshipQuery, encodeSpecificationQuery, encodeSummaryQuery, encodeTransitionQuery,
5
- encodeWorkcardQuery, modelOf,
4
+ CommitFailed, CommitState, DEFAULT_COMMIT_TIMEOUT, modelOf, PlanningError, PlanningUnsupported, wireHelper,
5
+ WorkcardKind, WorkcardNotFound, planningReplyHelper,
6
6
  } from '@owlmeans/planning'
7
7
  import type {
8
- PlanningFacade, PlanningProtocols, PlanningScope, TransitionReceipt,
9
- TransitionReceiptView, Workcard, WorkcardModel,
8
+ PlanningFacade, PlanningProtocols, PlanningScope, PlanningReply, TransitionReceipt,
9
+ TransitionReceiptView, Workcard, WorkcardModel, Specification, Relationship, Transition, SpecificationRevisionList, SummaryView,
10
10
  } from '@owlmeans/planning'
11
- import { applyReceipt } from './events.js'
11
+ import { makeRemoteResourceFacade } from './resources.js'
12
+ import { planningMirrorOf } from './events.js'
13
+ import { makePlanningClientLifecycle } from './lifecycle.js'
12
14
  import type { RemoteFacadeOptions } from './types.js'
13
15
 
14
16
  /**
@@ -30,8 +32,12 @@ export const makeRemoteFacade = <C extends BasicConfig, T extends BasicContext<C
30
32
  ): PlanningFacade => {
31
33
  const timeout = opts.timeout
32
34
  const { commits } = opts
35
+ const lifecycle = opts.lifecycle ?? makePlanningClientLifecycle({ scopeKey: opts.scopeKey })
33
36
 
34
- const receiptOf = (view: TransitionReceiptView): TransitionReceipt => {
37
+ const scopedCall = async <R>(run: (signal: AbortSignal) => Promise<PlanningReply<R>>): Promise<R> =>
38
+ lifecycle.run(async operation => planningReplyHelper.hydrate<R>(await operation.wait(run(operation.signal))))
39
+
40
+ const receiptOf = (view: TransitionReceiptView, key: string): TransitionReceipt => {
35
41
  const id = view.transition.id
36
42
  if (id == null) {
37
43
  throw new PlanningError('malformed:receipt-without-transition')
@@ -41,26 +47,30 @@ export const makeRemoteFacade = <C extends BasicConfig, T extends BasicContext<C
41
47
  transition: view.transition,
42
48
  ...(view.card !== undefined ? { card: view.card } : {}),
43
49
  committed: async waitOpts => {
44
- const { state, error } = view.transition.commit
45
- if (state === CommitState.Failed) {
46
- throw new CommitFailed(error ?? id)
47
- }
48
- if (state === CommitState.Committed && view.card !== undefined) {
49
- return view.card
50
- }
50
+ const operation = lifecycle.capture(key)
51
+ try {
52
+ const { state, error } = view.transition.commit
53
+ if (state === CommitState.Failed) {
54
+ throw new CommitFailed(error ?? id)
55
+ }
56
+ if (state === CommitState.Committed && view.card !== undefined) {
57
+ return view.card
58
+ }
51
59
 
52
- return await commits.wait(id, { timeout: waitOpts?.timeout ?? DEFAULT_COMMIT_TIMEOUT })
60
+ return await operation.wait(commits.wait(id, { timeout: waitOpts?.timeout ?? DEFAULT_COMMIT_TIMEOUT }))
61
+ } finally { operation.release() }
53
62
  },
54
63
  }
55
64
  }
56
65
 
57
66
  const facade: PlanningFacade = {
67
+ ...makeRemoteResourceFacade(context, protocols, { ...opts, lifecycle }),
58
68
  scope: { ...scope, entityId: scope.entityId ?? '' },
59
69
 
60
70
  schemas: opts.schemas,
61
71
 
62
72
  cards: {
63
- get: async id => await context.entrypoint(protocols.card.get).call({ params: { id }, timeout }),
73
+ get: async id => await scopedCall<Workcard>(signal => context.entrypoint(protocols.card.get).call({ params: { id }, timeout, signal })),
64
74
 
65
75
  load: async id => {
66
76
  try {
@@ -73,58 +83,58 @@ export const makeRemoteFacade = <C extends BasicConfig, T extends BasicContext<C
73
83
  }
74
84
  },
75
85
 
76
- list: async query => await context.entrypoint(protocols.card.list).call({
77
- query: encodeWorkcardQuery(query), timeout,
78
- }),
86
+ list: async query => await scopedCall<ListResult<Workcard>>(signal => context.entrypoint(protocols.card.list).call({
87
+ query: wireHelper.encodeWorkcardQuery(query), timeout, signal,
88
+ })),
79
89
 
80
90
  count: async query => {
81
91
  const { page: _page, size: _size, sort: _sort, ...where } = query ?? {}
82
- const answer = await context.entrypoint(protocols.card.list).call({
83
- query: encodeWorkcardQuery({ ...where, page: 0, size: 1 }), timeout,
84
- })
92
+ const answer = await scopedCall<ListResult<Workcard>>(signal => context.entrypoint(protocols.card.list).call({
93
+ query: wireHelper.encodeWorkcardQuery({ ...where, page: 0, size: 1 }), timeout, signal,
94
+ }))
85
95
 
86
96
  return answer.total
87
97
  },
88
98
 
89
- summary: async (parents, query) => await context.entrypoint(protocols.card.summary).call({
90
- query: encodeSummaryQuery({ ...query, parents }), timeout,
91
- }),
99
+ summary: async (parents, query) => await scopedCall<SummaryView>(signal => context.entrypoint(protocols.card.summary).call({
100
+ query: wireHelper.encodeSummaryQuery({ ...query, parents }), timeout, signal,
101
+ })),
92
102
  },
93
103
 
94
104
  specifications: {
95
105
  current: async (parent, category) => {
96
- const answer = await context.entrypoint(protocols.card.specifications).call({
97
- params: { id: parent }, query: encodeSpecificationQuery({ category }), timeout,
98
- })
106
+ const answer = await scopedCall<ListResult<Specification>>(signal => context.entrypoint(protocols.card.specifications).call({
107
+ params: { id: parent }, query: wireHelper.encodeSpecificationQuery({ category }), timeout, signal,
108
+ }))
99
109
 
100
110
  return answer.items[0] ?? null
101
111
  },
102
112
 
103
- list: async (parent, query) => await context.entrypoint(protocols.card.specifications).call({
104
- params: { id: parent }, query: encodeSpecificationQuery(query), timeout,
105
- }),
113
+ list: async (parent, query) => await scopedCall<ListResult<Specification>>(signal => context.entrypoint(protocols.card.specifications).call({
114
+ params: { id: parent }, query: wireHelper.encodeSpecificationQuery(query), timeout, signal,
115
+ })),
106
116
 
107
- get: async id => await context.entrypoint(protocols.spec.get).call({ params: { id }, timeout }),
117
+ get: async id => await scopedCall<Specification>(signal => context.entrypoint(protocols.spec.get).call({ params: { id }, timeout, signal })),
108
118
 
109
119
  revisions: async (id, limit) => {
110
- const answer = await context.entrypoint(protocols.spec.revisions).call({
111
- params: { id }, query: limit != null ? { limit } : {}, timeout,
112
- })
120
+ const answer = await scopedCall<SpecificationRevisionList>(signal => context.entrypoint(protocols.spec.revisions).call({
121
+ params: { id }, query: limit != null ? { limit } : {}, timeout, signal,
122
+ }))
113
123
 
114
124
  return answer.items
115
125
  },
116
126
  },
117
127
 
118
128
  relationships: {
119
- list: async query => await context.entrypoint(protocols.link.list).call({
120
- query: encodeRelationshipQuery(query), timeout,
121
- }),
129
+ list: async query => await scopedCall<ListResult<Relationship>>(signal => context.entrypoint(protocols.link.list).call({
130
+ query: wireHelper.encodeRelationshipQuery(query), timeout, signal,
131
+ })),
122
132
  },
123
133
 
124
134
  transitions: {
125
- get: async id => await context.entrypoint(protocols.transition.get).call({
126
- params: { transition: id }, timeout,
127
- }),
135
+ get: async id => await scopedCall<Transition>(signal => context.entrypoint(protocols.transition.get).call({
136
+ params: { transition: id }, timeout, signal,
137
+ })),
128
138
 
129
139
  list: async query => {
130
140
  // The tree reads the log through ONE card; a project-wide read has no route to travel.
@@ -132,36 +142,58 @@ export const makeRemoteFacade = <C extends BasicConfig, T extends BasicContext<C
132
142
  throw new PlanningUnsupported('client:transitions-without-card')
133
143
  }
134
144
 
135
- return await context.entrypoint(protocols.card.transitions).call({
136
- params: { id: query.card }, query: encodeTransitionQuery(query), timeout,
137
- })
145
+ return await scopedCall<ListResult<Transition>>(signal => context.entrypoint(protocols.card.transitions).call({
146
+ params: { id: query.card! }, query: wireHelper.encodeTransitionQuery(query), timeout, signal,
147
+ }))
138
148
  },
139
149
  },
140
150
 
141
151
  commits,
142
152
 
143
- execute: async (exec, executeOpts) => {
153
+ execute: async (exec, executeOpts) => lifecycle.run(async operation => {
144
154
  // Neither the actor nor a hold travels: the server decides who wrote, and the wait is ours.
145
155
  const { actor: _actor, ...body } = exec
146
- const view = await context.entrypoint(protocols.execute).call({ body, timeout })
156
+ const view = planningReplyHelper.hydrate<TransitionReceiptView>(await operation.wait(context.entrypoint(protocols.execute).call({ body, timeout, signal: operation.signal })))
147
157
  const stores = opts.stores?.()
148
158
  if (stores != null) {
149
- await applyReceipt(stores, view)
159
+ await planningMirrorOf(stores).applyReceipt(view, { lifecycle, operation })
150
160
  }
151
- const receipt = receiptOf(view)
161
+ operation.check()
162
+ const receipt = receiptOf(view, operation.key)
152
163
  if (executeOpts?.wait === true) {
153
- receipt.card = await receipt.committed({ timeout: executeOpts.timeout })
164
+ receipt.card = await operation.wait(receipt.committed({ timeout: executeOpts.timeout }))
154
165
  }
155
166
 
156
167
  return receipt
157
- },
168
+ }),
169
+
170
+ model: async <R extends Workcard = Workcard>(card: R | string): Promise<WorkcardModel<R>> => lifecycle.run(async operation => {
171
+ if (opts.loadSchemas != null) await operation.wait(opts.loadSchemas())
172
+ const record = typeof card === 'string' ? await operation.wait(facade.cards.get(card)) as R : card
173
+ // A card of a project resolves its types in that project's layer; the organization's layer
174
+ // is the service's own bundle, and a specification's type is always code's.
175
+ let project = opts.definitions == null ? undefined : record.kind === WorkcardKind.Project ? record.id : undefined
176
+ if (opts.definitions != null && record.kind === WorkcardKind.Card) {
177
+ let parent = record.parent
178
+ const seen = new Set<string>(record.id != null ? [record.id] : [])
179
+ while (parent != null) {
180
+ if (seen.has(parent)) throw new PlanningError('hierarchy:cycle')
181
+ seen.add(parent)
182
+ const ancestor = await operation.wait(facade.cards.get(parent))
183
+ if (ancestor.kind === WorkcardKind.Project) { project = ancestor.id; break }
184
+ parent = ancestor.parent
185
+ }
186
+ }
187
+ if (project == null) {
188
+ return modelOf<R>(record, facade)
189
+ }
158
190
 
159
- model: async <R extends Workcard = Workcard>(card: R | string): Promise<WorkcardModel<R>> => {
160
- await opts.loadSchemas?.()
161
- const record = typeof card === 'string' ? await facade.cards.get(card) as R : card
191
+ return modelOf<R>(record, { ...facade, schemas: await operation.wait(opts.definitions!.registry(project)) })
192
+ }),
193
+ }
162
194
 
163
- return modelOf<R>(record, facade)
164
- },
195
+ if (opts.definitions != null) {
196
+ facade.definitions = opts.definitions
165
197
  }
166
198
 
167
199
  return facade