@owlmeans/client-planning 0.1.18-rc.0

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 (67) hide show
  1. package/README.md +128 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/client-planning/SKILL.md +212 -0
  4. package/build/client.d.ts +26 -0
  5. package/build/client.d.ts.map +1 -0
  6. package/build/client.js +120 -0
  7. package/build/client.js.map +1 -0
  8. package/build/commits.d.ts +17 -0
  9. package/build/commits.d.ts.map +1 -0
  10. package/build/commits.js +205 -0
  11. package/build/commits.js.map +1 -0
  12. package/build/consts.d.ts +27 -0
  13. package/build/consts.d.ts.map +1 -0
  14. package/build/consts.js +29 -0
  15. package/build/consts.js.map +1 -0
  16. package/build/events.d.ts +27 -0
  17. package/build/events.d.ts.map +1 -0
  18. package/build/events.js +109 -0
  19. package/build/events.js.map +1 -0
  20. package/build/facade.d.ts +19 -0
  21. package/build/facade.d.ts.map +1 -0
  22. package/build/facade.js +130 -0
  23. package/build/facade.js.map +1 -0
  24. package/build/feed.d.ts +18 -0
  25. package/build/feed.d.ts.map +1 -0
  26. package/build/feed.js +123 -0
  27. package/build/feed.js.map +1 -0
  28. package/build/helper.d.ts +11 -0
  29. package/build/helper.d.ts.map +1 -0
  30. package/build/helper.js +10 -0
  31. package/build/helper.js.map +1 -0
  32. package/build/index.d.ts +10 -0
  33. package/build/index.d.ts.map +1 -0
  34. package/build/index.js +9 -0
  35. package/build/index.js.map +1 -0
  36. package/build/stores.d.ts +28 -0
  37. package/build/stores.d.ts.map +1 -0
  38. package/build/stores.js +61 -0
  39. package/build/stores.js.map +1 -0
  40. package/build/types.d.ts +147 -0
  41. package/build/types.d.ts.map +1 -0
  42. package/build/types.js +2 -0
  43. package/build/types.js.map +1 -0
  44. package/build/utils/record.d.ts +25 -0
  45. package/build/utils/record.d.ts.map +1 -0
  46. package/build/utils/record.js +72 -0
  47. package/build/utils/record.js.map +1 -0
  48. package/package.json +55 -0
  49. package/src/client.ts +153 -0
  50. package/src/commits.ts +247 -0
  51. package/src/consts.ts +38 -0
  52. package/src/events.ts +121 -0
  53. package/src/facade.ts +169 -0
  54. package/src/feed.ts +131 -0
  55. package/src/helper.ts +18 -0
  56. package/src/index.ts +10 -0
  57. package/src/stores.ts +81 -0
  58. package/src/types.ts +163 -0
  59. package/src/utils/record.ts +91 -0
  60. package/tests/commits.spec.ts +67 -0
  61. package/tests/context.ts +301 -0
  62. package/tests/events.spec.ts +95 -0
  63. package/tests/facade.spec.ts +83 -0
  64. package/tests/feed.spec.ts +39 -0
  65. package/tests/stores.spec.ts +67 -0
  66. package/tests/tsconfig.json +17 -0
  67. package/tsconfig.json +15 -0
package/src/client.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { bindAll } from '@owlmeans/client-entrypoint'
2
+ import { createLazyService, MiddlewareStage, MiddlewareType } from '@owlmeans/context'
3
+ import type { EntrypointTree } from '@owlmeans/entrypoint'
4
+ import {
5
+ DEFAULT_PLUGIN_ORDER, makeSchemaRegistry, PLANNING_SERVICE, PlanningUnsupported,
6
+ } from '@owlmeans/planning'
7
+ import type { PlanningFacade, PlanningPlugin, PlanningSchemaRegistry } from '@owlmeans/planning'
8
+ import { makeRemoteCommitSource } from './commits.js'
9
+ import { makeRemoteFacade } from './facade.js'
10
+ import { planningStoresOf } from './stores.js'
11
+ import type {
12
+ Config, Context, PlanningClientOptions, PlanningClientService, WithPlanningClient,
13
+ } from './types.js'
14
+
15
+ /**
16
+ * The client planning service: a remote facade factory under the SAME alias the server registers
17
+ * its service under, so `context.planning().for(scope)` is one call on both sides.
18
+ *
19
+ * What only a server can do is refused rather than faked: a client runs no middleware and folds
20
+ * nothing, so `store()`, `committed()` and a plugin carrying hooks or a store answer
21
+ * `PlanningUnsupported`. A plugin carrying only schemas is accepted — it layers over the server's
22
+ * bundle.
23
+ */
24
+ export const makePlanningClientService = <C extends Config, T extends Context<C>>(
25
+ context: T, options: PlanningClientOptions
26
+ ): PlanningClientService => {
27
+ const registry = makeSchemaRegistry()
28
+ const plugins: PlanningPlugin[] = []
29
+ const stores = () => planningStoresOf(context)
30
+
31
+ const commits = makeRemoteCommitSource(context, options.protocols, {
32
+ socket: options.socket, poll: options.poll, timeout: options.timeout, stores,
33
+ })
34
+
35
+ const layerPlugins = (): void => plugins.forEach(plugin => {
36
+ plugin.schemas?.flows?.forEach(registry.registerFlow)
37
+ plugin.schemas?.types?.forEach(registry.registerType)
38
+ })
39
+
40
+ let loaded = false
41
+ let loading: Promise<PlanningSchemaRegistry> | null = null
42
+
43
+ const loadSchemas: PlanningClientService['loadSchemas'] = async opts => {
44
+ if (loaded && opts?.force !== true) {
45
+ return registry
46
+ }
47
+ if (loading == null || opts?.force === true) {
48
+ const attempt = (async () => {
49
+ const bundle = await context.entrypoint(options.protocols.schema.list).call({ timeout: options.timeout })
50
+ registry.load(bundle)
51
+ layerPlugins()
52
+ loaded = true
53
+
54
+ return registry
55
+ })()
56
+ loading = attempt
57
+ // A failed load is forgotten, so the next caller asks again instead of inheriting the error.
58
+ attempt.catch(() => {
59
+ if (loading === attempt) {
60
+ loading = null
61
+ }
62
+ })
63
+ }
64
+
65
+ return await loading
66
+ }
67
+
68
+ const facadeFor = (scope: Parameters<PlanningClientService['for']>[0]): PlanningFacade =>
69
+ makeRemoteFacade(context, options.protocols, { ...options.scope, ...scope }, {
70
+ commits, schemas: registry, loadSchemas, timeout: options.timeout, stores,
71
+ })
72
+
73
+ let fallback: PlanningFacade | null = null
74
+
75
+ // Lazy, like the server's host: `context.planning()` answers before the context initializes.
76
+ return createLazyService<PlanningClientService>(PLANNING_SERVICE, {
77
+ schemas: registry,
78
+
79
+ commits,
80
+
81
+ loadSchemas,
82
+
83
+ use: plugin => {
84
+ if (plugin.before != null || plugin.after != null || plugin.store != null || plugin.mintCode != null) {
85
+ throw new PlanningUnsupported(`client:plugin:${plugin.name}`)
86
+ }
87
+ const existing = plugins.findIndex(candidate => candidate.name === plugin.name)
88
+ if (existing >= 0) {
89
+ plugins.splice(existing, 1)
90
+ }
91
+ plugins.push(plugin)
92
+ plugins.sort((left, right) => (left.order ?? DEFAULT_PLUGIN_ORDER) - (right.order ?? DEFAULT_PLUGIN_ORDER))
93
+ layerPlugins()
94
+ },
95
+
96
+ plugins: () => [...plugins],
97
+
98
+ store: () => {
99
+ throw new PlanningUnsupported('client:store')
100
+ },
101
+
102
+ for: scope => scope == null ? (fallback ??= facadeFor({})) : facadeFor(scope),
103
+
104
+ committed: async () => {
105
+ throw new PlanningUnsupported('client:committed')
106
+ },
107
+
108
+ close: async () => await commits.close(),
109
+ })
110
+ }
111
+
112
+ /**
113
+ * Wire the planning client into a client context: bind the protocol tree as client entrypoints
114
+ * (unless `bind: false`), register the service under `PLANNING_SERVICE`, and add
115
+ * `context.planning()`.
116
+ *
117
+ * The tree must be the one the server mounted — the same `makePlanningProtocols` options, alias for
118
+ * alias. When its commit feed hangs under a `socketBase`, that base belongs to the host and must be
119
+ * bound by the host too: a parent a registry cannot resolve fails the whole context at init.
120
+ *
121
+ * The schema bundle is fetched in the background once the context is READY, and a failure there is
122
+ * swallowed — a browser that is not signed in yet cannot read it, and a context must not fail over
123
+ * data it can fetch later. `model()` and `loadSchemas()` load it on first use regardless.
124
+ */
125
+ export const appendPlanningClient = <C extends Config, T extends Context<C>>(
126
+ context: T, options: PlanningClientOptions
127
+ ): T & WithPlanningClient => {
128
+ const ctx = context as T & WithPlanningClient
129
+
130
+ if (options.bind !== false) {
131
+ ctx.registerEntrypoints(bindAll(options.protocols as unknown as EntrypointTree))
132
+ }
133
+
134
+ const service = makePlanningClientService<C, T>(context, options)
135
+ ctx.registerService(service)
136
+ ctx.planning = () => ctx.service<PlanningClientService>(PLANNING_SERVICE)
137
+
138
+ if (options.schemas !== false) {
139
+ ctx.registerMiddleware({
140
+ type: MiddlewareType.Context,
141
+ stage: MiddlewareStage.Ready,
142
+ apply: async () => {
143
+ try {
144
+ await service.loadSchemas()
145
+ } catch {
146
+ // Best effort by design — see above.
147
+ }
148
+ },
149
+ })
150
+ }
151
+
152
+ return ctx
153
+ }
package/src/commits.ts ADDED
@@ -0,0 +1,247 @@
1
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
2
+ import {
3
+ CommitFailed, CommitState, CommitTimeout, DEFAULT_COMMIT_POLL, DEFAULT_COMMIT_TIMEOUT, MAX_COMMIT_POLL,
4
+ PLANNING_COMMIT_EVENT, PlanningError, WorkcardKind,
5
+ } from '@owlmeans/planning'
6
+ import type {
7
+ CommitEvent, CommitFilter, CommitStatus, PlanningProtocols, Unsubscribe, Workcard,
8
+ } from '@owlmeans/planning'
9
+ import type { Connection } from '@owlmeans/socket'
10
+ import { EARLY_POLL_LADDER, EARLY_POLL_MS, LONG_POLL_GRACE } from './consts.js'
11
+ import type { RemoteCommitSource, RemoteCommitSourceOptions } from './types.js'
12
+ import { dropCard, putCard, putCommit } from './utils/record.js'
13
+
14
+ interface Subscription {
15
+ listener: (event: CommitEvent) => void | Promise<void>
16
+ filter?: CommitFilter
17
+ }
18
+
19
+ const matches = (filter: CommitFilter | undefined, event: CommitEvent): boolean =>
20
+ filter == null || (
21
+ (filter.entityId == null || filter.entityId === event.entityId)
22
+ && (filter.project == null || filter.project === event.project)
23
+ && (filter.card == null || filter.card === event.card)
24
+ && (filter.kind == null || filter.kind === event.kind)
25
+ )
26
+
27
+ const delay = (ms: number): { promise: Promise<void>, cancel: () => void } => {
28
+ let handle: ReturnType<typeof setTimeout> | undefined
29
+ const promise = new Promise<void>(resolve => { handle = setTimeout(resolve, Math.max(0, ms)) })
30
+
31
+ return { promise, cancel: () => clearTimeout(handle) }
32
+ }
33
+
34
+ /**
35
+ * The commit source of a remote facade: the `commit.get` long poll and, when the host injects a
36
+ * socket opener, the `commit.events` feed.
37
+ *
38
+ * `wait` SUBSCRIBES FIRST and only then reads the status — a commit landing between a read and a
39
+ * subscription would otherwise be missed until the deadline. It then long-polls in holds of
40
+ * `min(poll, remaining)` seconds, so a client with no socket (a Node connector) still learns of a
41
+ * commit within one round trip of it landing, and a client with one learns from whichever arrives
42
+ * first. The deadline is a {@link CommitTimeout}; the transition stays pending — nothing is undone.
43
+ *
44
+ * ONE socket serves every subscription of the source; releasing a subscription never closes it.
45
+ */
46
+ export const makeRemoteCommitSource = <C extends BasicConfig, T extends BasicContext<C>>(
47
+ context: T, protocols: PlanningProtocols, opts?: RemoteCommitSourceOptions
48
+ ): RemoteCommitSource => {
49
+ const poll = Math.max(0, Math.min(MAX_COMMIT_POLL, opts?.poll ?? DEFAULT_COMMIT_POLL))
50
+ const subscriptions = new Set<Subscription>()
51
+
52
+ let opening: Promise<Connection | null> | null = null
53
+ let open: Connection | null = null
54
+ let detach: (() => void) | null = null
55
+
56
+ const dispatch = async (event: CommitEvent): Promise<void> => {
57
+ for (const subscription of [...subscriptions]) {
58
+ if (!matches(subscription.filter, event)) {
59
+ continue
60
+ }
61
+ try {
62
+ await subscription.listener(event)
63
+ } catch (e) {
64
+ console.error('Planning commit listener error:', e)
65
+ }
66
+ }
67
+ }
68
+
69
+ const socket = async (): Promise<Connection | null> => {
70
+ const opener = opts?.socket
71
+ if (opener == null) {
72
+ return null
73
+ }
74
+ opening ??= (async () => {
75
+ try {
76
+ const connection = await opener(protocols.commit.events, { query: {} })
77
+ if (connection == null) {
78
+ // No socket right now (not signed in yet, say) — the next subscription asks again.
79
+ opening = null
80
+ return null
81
+ }
82
+ open = connection
83
+ detach = connection.observe<CommitEvent>(PLANNING_COMMIT_EVENT, async message => {
84
+ await dispatch(message.payload)
85
+ })
86
+ return connection
87
+ } catch (e) {
88
+ opening = null
89
+ console.error('Planning commit socket error:', e)
90
+ return null
91
+ }
92
+ })()
93
+
94
+ return await opening
95
+ }
96
+
97
+ const endpoint = () => context.entrypoint(protocols.commit.get)
98
+
99
+ const status = async (transition: string): Promise<CommitStatus> => await endpoint().call({
100
+ params: { transition }, query: { wait: 0 }, timeout: opts?.timeout,
101
+ })
102
+
103
+ const hold = async (transition: string, wait: number, signal: AbortSignal): Promise<CommitStatus> => {
104
+ try {
105
+ return await endpoint().call({
106
+ params: { transition }, query: { wait }, timeout: wait * 1000 + LONG_POLL_GRACE, signal,
107
+ })
108
+ } catch (e) {
109
+ if (signal.aborted || e instanceof PlanningError) {
110
+ throw e
111
+ }
112
+ // A dropped hold (a proxy closing an idle response) is not an answer about the commit: one
113
+ // non-holding read replaces it, and a failure of THAT is reported.
114
+ return await status(transition)
115
+ }
116
+ }
117
+
118
+ /** Fold a settled status into the mirror, when the host registered one. */
119
+ const fold = async (transition: string, settled: CommitStatus): Promise<void> => {
120
+ const stores = opts?.stores?.()
121
+ if (stores == null) {
122
+ return
123
+ }
124
+ const known = await stores.commits.load(transition)
125
+ await putCommit(stores.commits, {
126
+ id: transition, state: settled.state, at: settled.at, error: settled.error,
127
+ })
128
+ if (settled.state !== CommitState.Committed) {
129
+ return
130
+ }
131
+ if (settled.card != null) {
132
+ await putCard(stores.cards, settled.card)
133
+ } else if (settled.card === null && known?.card != null) {
134
+ await dropCard(stores, known.card, known.kind === WorkcardKind.Project)
135
+ }
136
+ }
137
+
138
+ const answer = async (transition: string, settled: CommitStatus): Promise<Workcard | null> => {
139
+ await fold(transition, settled)
140
+ if (settled.state === CommitState.Failed) {
141
+ throw new CommitFailed(settled.error ?? transition)
142
+ }
143
+
144
+ return settled.card ?? null
145
+ }
146
+
147
+ const subscribe = async (
148
+ listener: Subscription['listener'], filter?: CommitFilter
149
+ ): Promise<Unsubscribe> => {
150
+ const subscription: Subscription = { listener, filter }
151
+ subscriptions.add(subscription)
152
+ await socket()
153
+
154
+ return () => { subscriptions.delete(subscription) }
155
+ }
156
+
157
+ const source: RemoteCommitSource = {
158
+ status,
159
+
160
+ subscribe,
161
+
162
+ wait: async (transition, waitOpts) => {
163
+ const deadline = Date.now() + (waitOpts?.timeout ?? DEFAULT_COMMIT_TIMEOUT)
164
+ // Held in an object: a frame writes it from a callback, which flow analysis cannot see.
165
+ const frame: { landed: CommitEvent | null } = { landed: null }
166
+ let wake: () => void = () => { }
167
+ const unsubscribe = await subscribe(event => {
168
+ if (event.transition === transition && event.state !== CommitState.Pending) {
169
+ frame.landed = event
170
+ wake()
171
+ }
172
+ })
173
+
174
+ /** A frame says it settled; the status read carries the card when the frame does not. */
175
+ const fromFrame = async (event: CommitEvent): Promise<Workcard | null> => event.record !== undefined
176
+ ? await answer(transition, {
177
+ transition, state: event.state, at: event.at, error: event.error, card: event.record,
178
+ })
179
+ : await answer(transition, await status(transition))
180
+
181
+ try {
182
+ let current = await status(transition)
183
+ let early = 0
184
+ while (current.state === CommitState.Pending) {
185
+ if (frame.landed != null) {
186
+ return await fromFrame(frame.landed)
187
+ }
188
+ const remaining = deadline - Date.now()
189
+ if (remaining <= 0) {
190
+ throw new CommitTimeout(transition)
191
+ }
192
+
193
+ const wait = Math.min(poll, Math.ceil(remaining / 1000))
194
+ const abort = new AbortController()
195
+ const timer = delay(remaining)
196
+ const framed = new Promise<'frame'>(resolve => { wake = () => resolve('frame') })
197
+ const started = Date.now()
198
+ try {
199
+ const outcome = await Promise.race([
200
+ hold(transition, wait, abort.signal),
201
+ framed,
202
+ timer.promise.then(() => 'deadline' as const),
203
+ ])
204
+ if (outcome === 'frame' || outcome === 'deadline') {
205
+ continue
206
+ }
207
+ current = outcome
208
+ } finally {
209
+ abort.abort()
210
+ timer.cancel()
211
+ wake = () => { }
212
+ }
213
+
214
+ if (current.state === CommitState.Pending && Date.now() - started < EARLY_POLL_MS) {
215
+ // The server did not hold — pause on a ladder rather than spin until the deadline.
216
+ const pause = delay(Math.min(
217
+ EARLY_POLL_LADDER[Math.min(early, EARLY_POLL_LADDER.length - 1)], deadline - Date.now()
218
+ ))
219
+ early += 1
220
+ await Promise.race([pause.promise, new Promise<void>(resolve => { wake = resolve })])
221
+ pause.cancel()
222
+ wake = () => { }
223
+ } else {
224
+ early = 0
225
+ }
226
+ }
227
+
228
+ return await answer(transition, current)
229
+ } finally {
230
+ unsubscribe()
231
+ }
232
+ },
233
+
234
+ connected: () => open != null,
235
+
236
+ close: async () => {
237
+ detach?.()
238
+ detach = null
239
+ const connection = open
240
+ open = null
241
+ opening = null
242
+ await connection?.close()
243
+ },
244
+ }
245
+
246
+ return source
247
+ }
package/src/consts.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { Relationship, Workcard } from '@owlmeans/planning'
2
+ import { stateAlias } from '@owlmeans/state'
3
+ import type { StateAlias } from '@owlmeans/state'
4
+ import type { PlanningCommitRecord, PlanningStoreAliases } from './types.js'
5
+
6
+ /**
7
+ * The ONE card store. Projects, cards and specifications share an id space on the server, so they
8
+ * share it here too — a store per kind would let a list reload of one kind drop another's rows.
9
+ */
10
+ export const CARDS: StateAlias<Workcard> = stateAlias<Workcard>('planning-card-state')
11
+
12
+ export const LINKS: StateAlias<Relationship> = stateAlias<Relationship>('planning-link-state')
13
+
14
+ /** What the client has learned about each transition it wrote or saw, keyed by transition id. */
15
+ export const COMMITS: StateAlias<PlanningCommitRecord> = stateAlias<PlanningCommitRecord>('planning-commit-state')
16
+
17
+ export const DEFAULT_STORE_ALIASES: Readonly<PlanningStoreAliases> = Object.freeze({
18
+ cards: CARDS,
19
+ links: LINKS,
20
+ commits: COMMITS,
21
+ })
22
+
23
+ /**
24
+ * How much longer than its own `wait` a long poll's HTTP deadline is, in milliseconds — a held
25
+ * response must outlast the hold, or every quiet poll reads as a broken connection.
26
+ */
27
+ export const LONG_POLL_GRACE = 10_000
28
+
29
+ /**
30
+ * The pause ladder between polls that came back pending without holding, in milliseconds.
31
+ *
32
+ * A server that does not hold (an old deployment, a proxy that answers early) would otherwise be
33
+ * polled in a tight loop until the deadline.
34
+ */
35
+ export const EARLY_POLL_LADDER: readonly number[] = Object.freeze([250, 1_000, 2_000])
36
+
37
+ /** A poll that answered faster than this did not hold. */
38
+ export const EARLY_POLL_MS = 250
package/src/events.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { CommitState, TransitionAction, WorkcardKind } from '@owlmeans/planning'
2
+ import type { CommitEvent, PlanningFacade, TransitionReceiptView, Workcard } from '@owlmeans/planning'
3
+ import { syncLinks } from './stores.js'
4
+ import type { PlanningStores } from './types.js'
5
+ import { dropCard, putCard, putCommit } from './utils/record.js'
6
+
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)
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
+ })
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
+ }
51
+
52
+ if (event.action === TransitionAction.Delete) {
53
+ await dropCard(stores, event.card, event.kind === WorkcardKind.Project)
54
+ return
55
+ }
56
+
57
+ const record: Workcard | null | undefined = event.record !== undefined
58
+ ? event.record
59
+ : facade != null ? await facade.cards.load(event.card) : undefined
60
+
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)
66
+ }
67
+
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
+ }
73
+
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
+ }
109
+
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
+ }
115
+
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
+ }
121
+ }