@owlmeans/client-planning 0.1.18-rc.9 → 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,18 @@
1
+ import type { Relationship, Workcard } from '@owlmeans/planning'
2
+ import type { Criteria } from '@owlmeans/resource'
3
+ import type { PlanningStores, SyncOptions } from '../types.js'
4
+
5
+ /** An authoritative list made true in one mirror store — within a scope, never beyond it. */
6
+ export interface SyncHelper {
7
+ /**
8
+ * Make the store agree with an authoritative list WITHIN a scope: every card given is written
9
+ * (unless the store holds a newer fold of it), and every card matching `where` that the list does
10
+ * not name is dropped. Cards outside `where` are left alone — which is why this, and never
11
+ * `replace()`, is how a list reaches the one shared card store.
12
+ *
13
+ * Unchanged cards are not rewritten, so a periodic re-seed wakes no subscriber.
14
+ */
15
+ syncCards: (store: PlanningStores['cards'], items: Workcard[], where?: Criteria<Workcard>, opts?: SyncOptions) => Promise<void>
16
+ /** {@link SyncHelper.syncCards} for the link store. */
17
+ syncLinks: (store: PlanningStores['links'], items: Relationship[], where?: Criteria<Relationship>, opts?: SyncOptions) => Promise<void>
18
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { Relationship, Workcard } from '@owlmeans/planning'
2
+ import type { Criteria } from '@owlmeans/resource'
3
+ import type { SyncHelper } from './sync/types.js'
4
+ import type { PlanningStores, SyncOptions } from './types.js'
5
+ import { recordUtils } from './utils/record.js'
6
+
7
+ export const createSyncHelper = (): SyncHelper => {
8
+ const syncCards = async (
9
+ store: PlanningStores['cards'], items: Workcard[], where?: Criteria<Workcard>, opts?: SyncOptions
10
+ ): Promise<void> => {
11
+ await dropUnnamed(store, recordUtils.idsOf(items), where, opts)
12
+ for (const item of items) {
13
+ await recordUtils.putCard(store, item)
14
+ }
15
+ }
16
+
17
+ const syncLinks = async (
18
+ store: PlanningStores['links'], items: Relationship[], where?: Criteria<Relationship>, opts?: SyncOptions
19
+ ): Promise<void> => {
20
+ await dropUnnamed(store, recordUtils.idsOf(items), where, opts)
21
+ for (const item of items) {
22
+ await recordUtils.putLink(store, item)
23
+ }
24
+ }
25
+
26
+ const dropUnnamed = async <T extends Workcard | Relationship>(
27
+ store: PlanningStores['cards'] | PlanningStores['links'], named: string[], where?: Criteria<T>, opts?: SyncOptions
28
+ ): Promise<void> => {
29
+ const keep = new Set([...named, ...(opts?.keep ?? [])])
30
+ const scoped = await (store as PlanningStores['cards']).list(where as Criteria<Workcard>)
31
+ const stale = recordUtils.idsOf(scoped.items).filter(id => !keep.has(id))
32
+ if (stale.length > 0) {
33
+ await store.purge({ id: { $in: stale } })
34
+ }
35
+ }
36
+
37
+ return { syncCards, syncLinks }
38
+ }
39
+
40
+ export const syncHelper = createSyncHelper()
@@ -0,0 +1,14 @@
1
+ import type { CommitEvent, CommitFilter } from '@owlmeans/planning'
2
+ import type { Connection } from '@owlmeans/socket'
3
+ import type { PlanningOperation } from './lifecycle/types.js'
4
+
5
+ export interface Subscription {
6
+ operation: PlanningOperation
7
+ listener: (event: CommitEvent) => void | Promise<void>
8
+ filter?: CommitFilter
9
+ }
10
+
11
+ export interface CommitSocketOpening {
12
+ operation: PlanningOperation
13
+ promise: Promise<Connection | null>
14
+ }
package/src/types.ts CHANGED
@@ -2,13 +2,14 @@ import type { ClientConfig, ClientContext } from '@owlmeans/client-context'
2
2
  import type { LazyService } from '@owlmeans/context'
3
3
  import type { AbstractRequest, EntrypointProtocolDeclaration } from '@owlmeans/entrypoint'
4
4
  import type {
5
- CommitFilter, CommitSource, CommitState, PlanningFacade, PlanningProtocols, PlanningSchemaRegistry,
6
- PlanningScope, PlanningService, Relationship, RelationshipQuery, TransitionAction, Workcard,
7
- WorkcardKind, WorkcardQuery,
5
+ CommitFilter, CommitSource, CommitState, PlanningDefinitions, PlanningFacade, PlanningProtocols,
6
+ PlanningSchemaRegistry, PlanningScope, PlanningService, Relationship, RelationshipQuery, TransitionAction,
7
+ Workcard, WorkcardKind, WorkcardQuery,
8
8
  } from '@owlmeans/planning'
9
9
  import type { Criteria, ResourceRecord } from '@owlmeans/resource'
10
10
  import type { Connection } from '@owlmeans/socket'
11
11
  import type { StateAlias, StateResource } from '@owlmeans/state'
12
+ import type { PlanningClientLifecycle } from './lifecycle/types.js'
12
13
 
13
14
  export interface Config extends ClientConfig { }
14
15
 
@@ -24,6 +25,8 @@ export interface PlanningSocketOpener {
24
25
  }
25
26
 
26
27
  export interface PlanningClientOptions {
28
+ /** Current organization slug and authenticated session identity; never an organization record id on the wire. */
29
+ scopeKey?: () => string | undefined
27
30
  /** The tree the server mounted — the same `makePlanningProtocols` call, alias for alias. */
28
31
  protocols: PlanningProtocols
29
32
  /**
@@ -49,13 +52,34 @@ export interface PlanningClientOptions {
49
52
 
50
53
  /** What `appendPlanningClient` registers under `PLANNING_SERVICE`. */
51
54
  export interface PlanningClientService extends PlanningService, LazyService {
55
+ /** Shared by stock facades and feeds; local mutations never cross its close boundary. */
56
+ lifecycle: PlanningClientLifecycle
57
+ scopeKey: () => string
58
+ registerFeed: (feed: PlanningFeed) => import('@owlmeans/planning').Unsubscribe
52
59
  /** A facade over the remote tree. The scope is advisory (see {@link PlanningClientOptions.scope}). */
53
60
  for: (scope?: Partial<PlanningScope>) => PlanningFacade
54
61
  commits: RemoteCommitSource
55
62
  /** The registry, loaded from the server's bundle — once, unless `force`d. */
56
63
  loadSchemas: (opts?: { force?: boolean }) => Promise<PlanningSchemaRegistry>
57
- /** Release the shared commit socket. */
64
+ /** Cancel old requests and feeds, drain local mutations, close the socket and clear mirrors. */
58
65
  close: () => Promise<void>
66
+ /** Data-defined types and flows — present when the tree was declared with `definitions: true`. */
67
+ definitions?: RemoteDefinitions
68
+ }
69
+
70
+ /** The client half of `PlanningDefinitions`: layers cached per project, dropped on any write. */
71
+ export interface RemoteDefinitions extends PlanningDefinitions {
72
+ /** Forget every cached layer — the next read asks the server. */
73
+ invalidate: () => void
74
+ }
75
+
76
+ export interface RemoteDefinitionsOptions {
77
+ lifecycle?: PlanningClientLifecycle
78
+ scopeKey?: () => string
79
+ /** Milliseconds, for every definitions call. */
80
+ timeout?: number
81
+ /** Called after every write (the service reloads its own bundle on the next `model()`). */
82
+ onWrite?: () => void
59
83
  }
60
84
 
61
85
  export interface WithPlanningClient {
@@ -65,11 +89,13 @@ export interface WithPlanningClient {
65
89
  export interface RemoteCommitSource extends CommitSource {
66
90
  /** A socket is open under this source. */
67
91
  connected: () => boolean
68
- /** Release the shared socket. Subscriptions stop receiving until the next `subscribe`. */
92
+ /** Cancel waits and subscriptions and release the socket, including a late-opening carrier. */
69
93
  close: () => Promise<void>
70
94
  }
71
95
 
72
96
  export interface RemoteCommitSourceOptions {
97
+ lifecycle?: PlanningClientLifecycle
98
+ scopeKey?: () => string
73
99
  socket?: PlanningSocketOpener
74
100
  /** Seconds. */
75
101
  poll?: number
@@ -80,12 +106,16 @@ export interface RemoteCommitSourceOptions {
80
106
  }
81
107
 
82
108
  export interface RemoteFacadeOptions {
109
+ lifecycle?: PlanningClientLifecycle
110
+ scopeKey?: () => string
83
111
  commits: RemoteCommitSource
84
112
  schemas: PlanningSchemaRegistry
85
113
  /** Resolves the registry before a model is built. */
86
114
  loadSchemas?: () => Promise<PlanningSchemaRegistry>
87
115
  timeout?: number
88
116
  stores?: () => PlanningStores | null
117
+ /** The data-defined schema surface, shared by every facade of the service. */
118
+ definitions?: RemoteDefinitions
89
119
  }
90
120
 
91
121
  /** One row per transition the client wrote or saw settle. */
@@ -105,12 +135,20 @@ export interface PlanningCommitRecord extends ResourceRecord {
105
135
  }
106
136
 
107
137
  export interface PlanningStoreAliases {
138
+ mentions: StateAlias<import('@owlmeans/planning').CommentMention>
139
+ comments: StateAlias<import('@owlmeans/planning').Comment>
140
+ teams: StateAlias<import('@owlmeans/planning').Team>
141
+ assignees: StateAlias<import('@owlmeans/planning').Assignee>
108
142
  cards: StateAlias<Workcard>
109
143
  links: StateAlias<Relationship>
110
144
  commits: StateAlias<PlanningCommitRecord>
111
145
  }
112
146
 
113
147
  export interface PlanningStores {
148
+ mentions: StateResource<import('@owlmeans/planning').CommentMention>
149
+ comments: StateResource<import('@owlmeans/planning').Comment>
150
+ teams: StateResource<import('@owlmeans/planning').Team>
151
+ assignees: StateResource<import('@owlmeans/planning').Assignee>
114
152
  cards: StateResource<Workcard>
115
153
  links: StateResource<Relationship>
116
154
  commits: StateResource<PlanningCommitRecord>
@@ -161,3 +199,13 @@ export interface PlanningFeed extends Readonly<PlanningFeedState> {
161
199
  /** Stop folding frames and refreshing. The shared socket stays open. */
162
200
  stop: () => Promise<void>
163
201
  }
202
+
203
+ export interface PlanningResourceFeedOptions {
204
+ assignees?: import('@owlmeans/planning').AssigneeQuery
205
+ teams?: import('@owlmeans/planning').TeamQuery
206
+ comments?: import('@owlmeans/planning').CommentQuery
207
+ mentions?: import('@owlmeans/planning').MentionQuery
208
+ refresh?: number
209
+ scope?: Partial<PlanningScope>
210
+ onChange?: (state: PlanningFeedState) => void
211
+ }
@@ -0,0 +1,27 @@
1
+ import type { CommitState, Relationship, Workcard } from '@owlmeans/planning'
2
+ import type { ResourceRecord } from '@owlmeans/resource'
3
+ import type { StateResource } from '@owlmeans/state'
4
+ import type { PlanningCommitRecord, PlanningStores } from '../../types.js'
5
+
6
+ /** How a record is written into the state mirror: only when it is newer, and only when it changed. */
7
+ export interface RecordUtils {
8
+ /** Same content, whatever order the keys arrived in. */
9
+ sameRecord: (left: unknown, right: unknown) => boolean
10
+ /**
11
+ * Write one card unless the store already holds a NEWER fold of it.
12
+ *
13
+ * `seq` is the last folded transition, so a lower one is an older picture — a list fetched before a
14
+ * commit landed must not undo that commit. The head only ever grows: a head this client allocated
15
+ * (`applyReceipt`) stays until a fold catches up with it.
16
+ */
17
+ putCard: (store: StateResource<Workcard>, card: Workcard) => Promise<Workcard>
18
+ putLink: (store: StateResource<Relationship>, link: Relationship) => Promise<void>
19
+ /** Drop a card, whatever lives under it when it is a project, and every link touching any of them. */
20
+ dropCard: (stores: PlanningStores, id: string, project: boolean) => Promise<void>
21
+ /** Record what is known about a transition, merged over what was known before. */
22
+ putCommit: (
23
+ store: StateResource<PlanningCommitRecord>, record: Partial<PlanningCommitRecord> & { id: string, state: CommitState }
24
+ ) => Promise<PlanningCommitRecord>
25
+ clean: <T extends object>(record: T) => T
26
+ idsOf: <T extends ResourceRecord>(records: T[]) => string[]
27
+ }
@@ -2,90 +2,87 @@ import type { PlanningCommitRecord, PlanningStores } from '../types.js'
2
2
  import type { CommitState, Relationship, Workcard } from '@owlmeans/planning'
3
3
  import type { ResourceRecord } from '@owlmeans/resource'
4
4
  import type { StateResource } from '@owlmeans/state'
5
+ import type { RecordUtils } from './record/types.js'
5
6
 
6
- const stable = (value: unknown): unknown => {
7
- if (Array.isArray(value)) {
8
- return value.map(stable)
9
- }
10
- if (value != null && typeof value === 'object') {
11
- return Object.fromEntries(Object.entries(value as Record<string, unknown>)
12
- .filter(([, entry]) => entry !== undefined)
13
- .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
14
- .map(([key, entry]) => [key, stable(entry)]))
7
+ export const createRecordUtils = (): RecordUtils => {
8
+ const stable = (value: unknown): unknown => {
9
+ if (Array.isArray(value)) {
10
+ return value.map(stable)
11
+ }
12
+ if (value != null && typeof value === 'object') {
13
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>)
14
+ .filter(([, entry]) => entry !== undefined)
15
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
16
+ .map(([key, entry]) => [key, stable(entry)]))
17
+ }
18
+
19
+ return value
15
20
  }
16
21
 
17
- return value
18
- }
22
+ const sameRecord = (left: unknown, right: unknown): boolean =>
23
+ JSON.stringify(stable(left)) === JSON.stringify(stable(right))
19
24
 
20
- /** Same content, whatever order the keys arrived in. */
21
- export const sameRecord = (left: unknown, right: unknown): boolean =>
22
- JSON.stringify(stable(left)) === JSON.stringify(stable(right))
25
+ const putCard = async (store: StateResource<Workcard>, card: Workcard): Promise<Workcard> => {
26
+ if (card.id == null) {
27
+ return card
28
+ }
29
+ const known = await store.load(card.id)
30
+ if (known != null && (known.seq ?? 0) > (card.seq ?? 0)) {
31
+ return known
32
+ }
33
+ const head = Math.max(card.head ?? card.seq ?? 0, known?.head ?? 0)
34
+ const next = head > (card.head ?? card.seq ?? 0) ? { ...card, head } : card
35
+ if (known != null && sameRecord(known, next)) {
36
+ return known
37
+ }
38
+ await store.save(next)
23
39
 
24
- /**
25
- * Write one card unless the store already holds a NEWER fold of it.
26
- *
27
- * `seq` is the last folded transition, so a lower one is an older picture — a list fetched before a
28
- * commit landed must not undo that commit. The head only ever grows: a head this client allocated
29
- * (`applyReceipt`) stays until a fold catches up with it.
30
- */
31
- export const putCard = async (store: StateResource<Workcard>, card: Workcard): Promise<Workcard> => {
32
- if (card.id == null) {
33
- return card
40
+ return next
34
41
  }
35
- const known = await store.load(card.id)
36
- if (known != null && (known.seq ?? 0) > (card.seq ?? 0)) {
37
- return known
42
+
43
+ const putLink = async (store: StateResource<Relationship>, link: Relationship): Promise<void> => {
44
+ if (link.id == null) {
45
+ return
46
+ }
47
+ const known = await store.load(link.id)
48
+ if (known != null && sameRecord(known, link)) {
49
+ return
50
+ }
51
+ await store.save(link)
38
52
  }
39
- const head = Math.max(card.head ?? card.seq ?? 0, known?.head ?? 0)
40
- const next = head > (card.head ?? card.seq ?? 0) ? { ...card, head } : card
41
- if (known != null && sameRecord(known, next)) {
42
- return known
53
+
54
+ const dropCard = async (stores: PlanningStores, id: string, project: boolean): Promise<void> => {
55
+ const ids = [id]
56
+ if (project) {
57
+ const children = await stores.cards.list({ parents: { $contains: [id] } })
58
+ ids.push(...children.items.map(child => child.id).filter((child): child is string => child != null))
59
+ }
60
+ await stores.cards.purge({ id: { $in: ids } })
61
+ if (await stores.links.count({ $or: [{ from: { $in: ids } }, { to: { $in: ids } }] }) > 0) {
62
+ await stores.links.purge({ $or: [{ from: { $in: ids } }, { to: { $in: ids } }] })
63
+ }
43
64
  }
44
- await store.save(next)
45
65
 
46
- return next
47
- }
66
+ const putCommit = async (
67
+ store: StateResource<PlanningCommitRecord>, record: Partial<PlanningCommitRecord> & { id: string, state: CommitState }
68
+ ): Promise<PlanningCommitRecord> => {
69
+ const known = await store.load(record.id)
70
+ const next = { ...known, ...clean(record) } as PlanningCommitRecord
71
+ if (known != null && sameRecord(known, next)) {
72
+ return known
73
+ }
74
+ await store.save(next)
48
75
 
49
- export const putLink = async (store: StateResource<Relationship>, link: Relationship): Promise<void> => {
50
- if (link.id == null) {
51
- return
52
- }
53
- const known = await store.load(link.id)
54
- if (known != null && sameRecord(known, link)) {
55
- return
76
+ return next
56
77
  }
57
- await store.save(link)
58
- }
59
78
 
60
- /** Drop a card, whatever lives under it when it is a project, and every link touching any of them. */
61
- export const dropCard = async (stores: PlanningStores, id: string, project: boolean): Promise<void> => {
62
- const ids = [id]
63
- if (project) {
64
- const children = await stores.cards.list({ parents: { $contains: [id] } })
65
- ids.push(...children.items.map(child => child.id).filter((child): child is string => child != null))
66
- }
67
- await stores.cards.purge({ id: { $in: ids } })
68
- if (await stores.links.count({ $or: [{ from: { $in: ids } }, { to: { $in: ids } }] }) > 0) {
69
- await stores.links.purge({ $or: [{ from: { $in: ids } }, { to: { $in: ids } }] })
70
- }
71
- }
79
+ const clean = <T extends object>(record: T): T =>
80
+ Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
72
81
 
73
- /** Record what is known about a transition, merged over what was known before. */
74
- export const putCommit = async (
75
- store: StateResource<PlanningCommitRecord>, record: Partial<PlanningCommitRecord> & { id: string, state: CommitState }
76
- ): Promise<PlanningCommitRecord> => {
77
- const known = await store.load(record.id)
78
- const next = { ...known, ...clean(record) } as PlanningCommitRecord
79
- if (known != null && sameRecord(known, next)) {
80
- return known
81
- }
82
- await store.save(next)
82
+ const idsOf = <T extends ResourceRecord>(records: T[]): string[] =>
83
+ records.map(record => record.id).filter((id): id is string => id != null)
83
84
 
84
- return next
85
+ return { sameRecord, putCard, putLink, dropCard, putCommit, clean, idsOf }
85
86
  }
86
87
 
87
- export const clean = <T extends object>(record: T): T =>
88
- Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
89
-
90
- export const idsOf = <T extends ResourceRecord>(records: T[]): string[] =>
91
- records.map(record => record.id).filter((id): id is string => id != null)
88
+ export const recordUtils = createRecordUtils()