@owlmeans/client-planning 0.1.18-rc.21 → 0.1.18-rc.22

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 (87) hide show
  1. package/README.md +13 -8
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/client-planning/SKILL.md +24 -23
  4. package/build/client.js +2 -2
  5. package/build/client.js.map +1 -1
  6. package/build/commits.js +4 -4
  7. package/build/commits.js.map +1 -1
  8. package/build/consts.d.ts +12 -0
  9. package/build/consts.d.ts.map +1 -1
  10. package/build/consts.js +15 -0
  11. package/build/consts.js.map +1 -1
  12. package/build/definitions.js +2 -2
  13. package/build/definitions.js.map +1 -1
  14. package/build/events/types.d.ts +29 -0
  15. package/build/events/types.d.ts.map +1 -0
  16. package/build/events/types.js.map +1 -0
  17. package/build/events.d.ts +9 -20
  18. package/build/events.d.ts.map +1 -1
  19. package/build/events.js +97 -103
  20. package/build/events.js.map +1 -1
  21. package/build/facade.d.ts.map +1 -1
  22. package/build/facade.js +10 -10
  23. package/build/facade.js.map +1 -1
  24. package/build/feed.d.ts +4 -4
  25. package/build/feed.d.ts.map +1 -1
  26. package/build/feed.js +17 -15
  27. package/build/feed.js.map +1 -1
  28. package/build/helper/types.d.ts +15 -0
  29. package/build/helper/types.d.ts.map +1 -0
  30. package/build/helper/types.js +2 -0
  31. package/build/helper/types.js.map +1 -0
  32. package/build/helper.d.ts +7 -7
  33. package/build/helper.d.ts.map +1 -1
  34. package/build/helper.js +16 -8
  35. package/build/helper.js.map +1 -1
  36. package/build/index.d.ts +4 -1
  37. package/build/index.d.ts.map +1 -1
  38. package/build/index.js +1 -1
  39. package/build/index.js.map +1 -1
  40. package/build/stores.d.ts +3 -10
  41. package/build/stores.d.ts.map +1 -1
  42. package/build/stores.js +9 -36
  43. package/build/stores.js.map +1 -1
  44. package/build/sync/types.d.ts +18 -0
  45. package/build/sync/types.d.ts.map +1 -0
  46. package/build/sync/types.js +2 -0
  47. package/build/{utils → sync}/types.js.map +1 -1
  48. package/build/sync.d.ts +4 -0
  49. package/build/sync.d.ts.map +1 -0
  50. package/build/sync.js +26 -0
  51. package/build/sync.js.map +1 -0
  52. package/build/utils/record/types.d.ts +28 -0
  53. package/build/utils/record/types.d.ts.map +1 -0
  54. package/build/utils/record/types.js +2 -0
  55. package/build/utils/record/types.js.map +1 -0
  56. package/build/utils/record.d.ts +3 -43
  57. package/build/utils/record.d.ts.map +1 -1
  58. package/build/utils/record.js +64 -72
  59. package/build/utils/record.js.map +1 -1
  60. package/package.json +15 -15
  61. package/src/client.ts +2 -2
  62. package/src/commits.ts +4 -4
  63. package/src/consts.ts +23 -0
  64. package/src/definitions.ts +2 -2
  65. package/src/events/types.ts +29 -0
  66. package/src/events.ts +105 -103
  67. package/src/facade.ts +11 -12
  68. package/src/feed.ts +17 -15
  69. package/src/helper/types.ts +15 -0
  70. package/src/helper.ts +27 -10
  71. package/src/index.ts +4 -1
  72. package/src/stores.ts +9 -39
  73. package/src/sync/types.ts +18 -0
  74. package/src/sync.ts +40 -0
  75. package/src/utils/record/types.ts +27 -0
  76. package/src/utils/record.ts +68 -75
  77. package/tests/events.spec.ts +12 -8
  78. package/tests/stores.spec.ts +13 -11
  79. package/build/utils/types.d.ts +0 -3
  80. package/build/utils/types.d.ts.map +0 -1
  81. package/build/utils.d.ts +0 -13
  82. package/build/utils.d.ts.map +0 -1
  83. package/build/utils.js +0 -15
  84. package/build/utils.js.map +0 -1
  85. package/src/utils/types.ts +0 -3
  86. package/src/utils.ts +0 -20
  87. /package/build/{utils → events}/types.js +0 -0
package/src/events.ts CHANGED
@@ -1,120 +1,122 @@
1
+ import { memoHelper } from '@owlmeans/context'
1
2
  import { CommitState, TransitionAction, WorkcardKind, type CommitEvent, type PlanningFacade, type TransitionReceiptView, type Workcard } from '@owlmeans/planning'
2
- import { syncLinks } from './stores.js'
3
+ import type { PlanningMirror } from './events/types.js'
4
+ import { syncHelper } from './sync.js'
3
5
  import type { PlanningStores } from './types.js'
4
- import { recordHelper } from './utils/record.js'
6
+ import { recordUtils } from './utils/record.js'
5
7
 
6
- /**
7
- * Fold one commit frame into the mirror.
8
- *
9
- * - An OLDER `seq` never overwrites a newer record — frames and list answers race.
10
- * - A committed `delete` REMOVES the row and every link touching it (and, for a project, the rows
11
- * under it — the server purged them in the same fold).
12
- * - A frame for an id the store never saw still writes a row: a card created in another tab
13
- * appears here without waiting for the next list.
14
- * - A `failed` commit leaves the card alone; the reason is recorded in the commit store.
15
- *
16
- * A frame from a cross-process bus carries ids only. When `record` is absent the card is re-read
17
- * through `facade`; without one there is nothing to write but the commit itself.
18
- */
19
- export const applyCommitEvent = async (
20
- stores: PlanningStores, event: CommitEvent, facade?: PlanningFacade
21
- ): Promise<void> => {
22
- const known = await stores.cards.load(event.card)
8
+ export const makePlanningMirror = (stores: PlanningStores): PlanningMirror => {
9
+ const applyCommitEvent = async (event: CommitEvent, facade?: PlanningFacade): Promise<void> => {
10
+ const known = await stores.cards.load(event.card)
23
11
 
24
- await recordHelper.putCommit(stores.commits, {
25
- id: event.transition,
26
- card: event.card,
27
- entityId: event.entityId,
28
- project: event.project,
29
- kind: event.kind,
30
- type: event.type,
31
- seq: event.seq,
32
- action: event.action,
33
- state: event.state,
34
- at: event.at,
35
- error: event.error,
36
- })
12
+ await recordUtils.putCommit(stores.commits, {
13
+ id: event.transition,
14
+ card: event.card,
15
+ entityId: event.entityId,
16
+ project: event.project,
17
+ kind: event.kind,
18
+ type: event.type,
19
+ seq: event.seq,
20
+ action: event.action,
21
+ state: event.state,
22
+ at: event.at,
23
+ error: event.error,
24
+ })
37
25
 
38
- if (event.state === CommitState.Failed) {
39
- return
40
- }
41
- if (event.state === CommitState.Pending) {
42
- if (known != null && (known.head ?? known.seq) < event.seq) {
43
- await stores.cards.save({ ...known, head: event.seq })
26
+ if (event.state === CommitState.Failed) {
27
+ return
28
+ }
29
+ if (event.state === CommitState.Pending) {
30
+ if (known != null && (known.head ?? known.seq) < event.seq) {
31
+ await stores.cards.save({ ...known, head: event.seq })
32
+ }
33
+ return
34
+ }
35
+ if (known != null && known.seq > event.seq) {
36
+ return
44
37
  }
45
- return
46
- }
47
- if (known != null && known.seq > event.seq) {
48
- return
49
- }
50
38
 
51
- if (event.action === TransitionAction.Delete) {
52
- await recordHelper.dropCard(stores, event.card, event.kind === WorkcardKind.Project)
53
- return
54
- }
39
+ if (event.action === TransitionAction.Delete) {
40
+ await recordUtils.dropCard(stores, event.card, event.kind === WorkcardKind.Project)
41
+ return
42
+ }
55
43
 
56
- const record: Workcard | null | undefined = event.record !== undefined
57
- ? event.record
58
- : facade != null ? await facade.cards.load(event.card) : undefined
44
+ const record: Workcard | null | undefined = event.record !== undefined
45
+ ? event.record
46
+ : facade != null ? await facade.cards.load(event.card) : undefined
59
47
 
60
- if (record === null) {
61
- // Committed, and gone by the time it was read: a later delete won the race.
62
- await recordHelper.dropCard(stores, event.card, event.kind === WorkcardKind.Project)
63
- } else if (record !== undefined) {
64
- await recordHelper.putCard(stores.cards, record)
65
- }
48
+ if (record === null) {
49
+ // Committed, and gone by the time it was read: a later delete won the race.
50
+ await recordUtils.dropCard(stores, event.card, event.kind === WorkcardKind.Project)
51
+ } else if (record !== undefined) {
52
+ await recordUtils.putCard(stores.cards, record)
53
+ }
66
54
 
67
- if (facade != null && (event.action === TransitionAction.Link || event.action === TransitionAction.Unlink)) {
68
- const links = await facade.relationships.list({ from: event.card, size: 0 })
69
- await syncLinks(stores.links, links.items, { from: event.card })
55
+ if (facade != null && (event.action === TransitionAction.Link || event.action === TransitionAction.Unlink)) {
56
+ const links = await facade.relationships.list({ from: event.card, size: 0 })
57
+ await syncHelper.syncLinks(stores.links, links.items, { from: event.card })
58
+ }
70
59
  }
71
- }
72
60
 
73
- /**
74
- * Mark a receipt in the mirror the moment the server answers it.
75
- *
76
- * A pending transition raises the card's `head` to its `seq`, so `head > seq` — the model's
77
- * `pending()` — is true before any frame arrives. A receipt that already carries the committed
78
- * card writes it; a committed delete drops the row.
79
- */
80
- export const applyReceipt = async (stores: PlanningStores, receipt: TransitionReceiptView): Promise<void> => {
81
- const { transition } = receipt
82
- if (transition.id != null) {
83
- await recordHelper.putCommit(stores.commits, {
84
- id: transition.id,
85
- card: transition.card,
86
- entityId: transition.entityId,
87
- project: transition.project,
88
- kind: transition.kind,
89
- type: transition.type,
90
- seq: transition.seq,
91
- action: transition.action,
92
- state: transition.commit.state,
93
- at: transition.commit.at,
94
- error: transition.commit.error,
95
- })
96
- }
97
- if (transition.commit.state === CommitState.Failed) {
98
- return
99
- }
100
- if (transition.commit.state === CommitState.Committed && transition.action === TransitionAction.Delete) {
101
- await recordHelper.dropCard(stores, transition.card, transition.kind === WorkcardKind.Project)
102
- return
103
- }
104
- if (receipt.card != null) {
105
- await recordHelper.putCard(stores.cards, receipt.card)
106
- return
107
- }
61
+ const applyReceipt = async (receipt: TransitionReceiptView): Promise<void> => {
62
+ const { transition } = receipt
63
+ if (transition.id != null) {
64
+ await recordUtils.putCommit(stores.commits, {
65
+ id: transition.id,
66
+ card: transition.card,
67
+ entityId: transition.entityId,
68
+ project: transition.project,
69
+ kind: transition.kind,
70
+ type: transition.type,
71
+ seq: transition.seq,
72
+ action: transition.action,
73
+ state: transition.commit.state,
74
+ at: transition.commit.at,
75
+ error: transition.commit.error,
76
+ })
77
+ }
78
+ if (transition.commit.state === CommitState.Failed) {
79
+ return
80
+ }
81
+ if (transition.commit.state === CommitState.Committed && transition.action === TransitionAction.Delete) {
82
+ await recordUtils.dropCard(stores, transition.card, transition.kind === WorkcardKind.Project)
83
+ return
84
+ }
85
+ if (receipt.card != null) {
86
+ await recordUtils.putCard(stores.cards, receipt.card)
87
+ return
88
+ }
108
89
 
109
- const known = await stores.cards.load(transition.card)
110
- if (known != null && (known.head ?? known.seq) < transition.seq) {
111
- await stores.cards.save({ ...known, head: transition.seq })
90
+ const known = await stores.cards.load(transition.card)
91
+ if (known != null && (known.head ?? known.seq) < transition.seq) {
92
+ await stores.cards.save({ ...known, head: transition.seq })
93
+ }
112
94
  }
113
- }
114
95
 
115
- /** Write cards the caller read on its own, under the same newer-fold-wins rule as a commit. */
116
- export const applyCards = async (stores: Pick<PlanningStores, 'cards'>, cards: Workcard[]): Promise<void> => {
117
- for (const card of cards) {
118
- await recordHelper.putCard(stores.cards, card)
96
+ const applyCards = async (cards: Workcard[]): Promise<void> => {
97
+ for (const card of cards) {
98
+ await recordUtils.putCard(stores.cards, card)
99
+ }
119
100
  }
101
+
102
+ return { applyCommitEvent, applyReceipt, applyCards }
120
103
  }
104
+
105
+ /** The mirror of one set of stores — one per stores object. */
106
+ export const planningMirrorOf = memoHelper.oncePer(makePlanningMirror)
107
+
108
+ /** @deprecated compat:factory-refactor — use `planningMirrorOf(stores).applyCommitEvent(…)` */
109
+ export const applyCommitEvent = async (
110
+ stores: PlanningStores, event: CommitEvent, facade?: PlanningFacade
111
+ ): Promise<void> => await planningMirrorOf(stores).applyCommitEvent(event, facade)
112
+
113
+ /** @deprecated compat:factory-refactor — use `planningMirrorOf(stores).applyReceipt(…)` */
114
+ export const applyReceipt = async (stores: PlanningStores, receipt: TransitionReceiptView): Promise<void> =>
115
+ await planningMirrorOf(stores).applyReceipt(receipt)
116
+
117
+ /**
118
+ * @deprecated compat:factory-refactor — use `planningMirrorOf(stores).applyCards(…)`. Only `cards` is
119
+ * read, so a narrower stores object still works here.
120
+ */
121
+ export const applyCards = async (stores: Pick<PlanningStores, 'cards'>, cards: Workcard[]): Promise<void> =>
122
+ await planningMirrorOf(stores as PlanningStores).applyCards(cards)
package/src/facade.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  import type { BasicConfig, BasicContext } from '@owlmeans/context'
2
2
  import {
3
- CommitFailed, CommitState, DEFAULT_COMMIT_TIMEOUT, PlanningError, PlanningUnsupported, WorkcardKind, WorkcardNotFound,
4
- encodeRelationshipQuery, encodeSpecificationQuery, encodeSummaryQuery, encodeTransitionQuery,
5
- encodeWorkcardQuery, modelOf,
3
+ CommitFailed, CommitState, DEFAULT_COMMIT_TIMEOUT, modelOf, PlanningError, PlanningUnsupported, wireHelper,
4
+ WorkcardKind, WorkcardNotFound,
6
5
  } from '@owlmeans/planning'
7
6
  import type {
8
7
  PlanningFacade, PlanningProtocols, PlanningScope, TransitionReceipt,
9
8
  TransitionReceiptView, Workcard, WorkcardModel,
10
9
  } from '@owlmeans/planning'
11
- import { applyReceipt } from './events.js'
10
+ import { planningMirrorOf } from './events.js'
12
11
  import type { RemoteFacadeOptions } from './types.js'
13
12
 
14
13
  /**
@@ -74,34 +73,34 @@ export const makeRemoteFacade = <C extends BasicConfig, T extends BasicContext<C
74
73
  },
75
74
 
76
75
  list: async query => await context.entrypoint(protocols.card.list).call({
77
- query: encodeWorkcardQuery(query), timeout,
76
+ query: wireHelper.encodeWorkcardQuery(query), timeout,
78
77
  }),
79
78
 
80
79
  count: async query => {
81
80
  const { page: _page, size: _size, sort: _sort, ...where } = query ?? {}
82
81
  const answer = await context.entrypoint(protocols.card.list).call({
83
- query: encodeWorkcardQuery({ ...where, page: 0, size: 1 }), timeout,
82
+ query: wireHelper.encodeWorkcardQuery({ ...where, page: 0, size: 1 }), timeout,
84
83
  })
85
84
 
86
85
  return answer.total
87
86
  },
88
87
 
89
88
  summary: async (parents, query) => await context.entrypoint(protocols.card.summary).call({
90
- query: encodeSummaryQuery({ ...query, parents }), timeout,
89
+ query: wireHelper.encodeSummaryQuery({ ...query, parents }), timeout,
91
90
  }),
92
91
  },
93
92
 
94
93
  specifications: {
95
94
  current: async (parent, category) => {
96
95
  const answer = await context.entrypoint(protocols.card.specifications).call({
97
- params: { id: parent }, query: encodeSpecificationQuery({ category }), timeout,
96
+ params: { id: parent }, query: wireHelper.encodeSpecificationQuery({ category }), timeout,
98
97
  })
99
98
 
100
99
  return answer.items[0] ?? null
101
100
  },
102
101
 
103
102
  list: async (parent, query) => await context.entrypoint(protocols.card.specifications).call({
104
- params: { id: parent }, query: encodeSpecificationQuery(query), timeout,
103
+ params: { id: parent }, query: wireHelper.encodeSpecificationQuery(query), timeout,
105
104
  }),
106
105
 
107
106
  get: async id => await context.entrypoint(protocols.spec.get).call({ params: { id }, timeout }),
@@ -117,7 +116,7 @@ export const makeRemoteFacade = <C extends BasicConfig, T extends BasicContext<C
117
116
 
118
117
  relationships: {
119
118
  list: async query => await context.entrypoint(protocols.link.list).call({
120
- query: encodeRelationshipQuery(query), timeout,
119
+ query: wireHelper.encodeRelationshipQuery(query), timeout,
121
120
  }),
122
121
  },
123
122
 
@@ -133,7 +132,7 @@ export const makeRemoteFacade = <C extends BasicConfig, T extends BasicContext<C
133
132
  }
134
133
 
135
134
  return await context.entrypoint(protocols.card.transitions).call({
136
- params: { id: query.card }, query: encodeTransitionQuery(query), timeout,
135
+ params: { id: query.card }, query: wireHelper.encodeTransitionQuery(query), timeout,
137
136
  })
138
137
  },
139
138
  },
@@ -146,7 +145,7 @@ export const makeRemoteFacade = <C extends BasicConfig, T extends BasicContext<C
146
145
  const view = await context.entrypoint(protocols.execute).call({ body, timeout })
147
146
  const stores = opts.stores?.()
148
147
  if (stores != null) {
149
- await applyReceipt(stores, view)
148
+ await planningMirrorOf(stores).applyReceipt(view)
150
149
  }
151
150
  const receipt = receiptOf(view)
152
151
  if (executeOpts?.wait === true) {
package/src/feed.ts CHANGED
@@ -1,26 +1,27 @@
1
1
  import type { BasicConfig, BasicContext } from '@owlmeans/context'
2
2
  import { logger } from '@owlmeans/log'
3
- import { criteriaOf, PLANNING_SERVICE, type Relationship, type RelationshipQuery, type Unsubscribe } from '@owlmeans/planning'
3
+ import { PLANNING_SERVICE, queryHelper, type Relationship, type RelationshipQuery, type Unsubscribe } from '@owlmeans/planning'
4
4
  import type { Criteria } from '@owlmeans/resource'
5
- import { applyCards, applyCommitEvent } from './events.js'
6
- import { planningStoresOf, syncCards, syncLinks } from './stores.js'
5
+ import { planningMirrorOf } from './events.js'
6
+ import { planningContextOf } from './helper.js'
7
+ import { syncHelper } from './sync.js'
7
8
  import type { PlanningClientService, PlanningFeed, PlanningFeedOptions, PlanningFeedState } from './types.js'
8
- import { recordHelper } from './utils/record.js'
9
+ import { recordUtils } from './utils/record.js'
9
10
 
10
11
  const log = logger('client-planning')
11
12
 
12
13
  const linkCriteriaOf = (query: RelationshipQuery): Criteria<Relationship> =>
13
- recordHelper.clean({ from: query.from, to: query.to, type: query.type }) as Criteria<Relationship>
14
+ recordUtils.clean({ from: query.from, to: query.to, type: query.type }) as Criteria<Relationship>
14
15
 
15
16
  /**
16
17
  * Keep the state mirror current: subscribe to commits FIRST, then seed from the list, then fold
17
18
  * every frame — and, with `refresh`, re-seed on an interval as the authoritative backstop to a
18
19
  * socket that can drop frames. React-free: a hook wraps it, a Node process uses it as it is.
19
20
  *
20
- * The seed is `syncCards` over `where` (default `criteriaOf(query)`), never `replace()`: the store
21
- * holds every kind, and a board's list must not drop the projects beside it. A card a frame wrote
22
- * while the list was in flight is kept even when the list does not name it yet. A PAGED seed only
23
- * writes — a page cannot say what does not exist.
21
+ * The seed is `syncHelper.syncCards` over `where` (default `queryHelper.criteriaOf(query)`), never
22
+ * `replace()`: the store holds every kind, and a board's list must not drop the projects beside it. A
23
+ * card a frame wrote while the list was in flight is kept even when the list does not name it yet. A
24
+ * PAGED seed only writes — a page cannot say what does not exist.
24
25
  *
25
26
  * Without a socket opener the feed is a seed plus its refresh; `commits.wait` still long-polls.
26
27
  *
@@ -29,12 +30,13 @@ const linkCriteriaOf = (query: RelationshipQuery): Criteria<Relationship> =>
29
30
  export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C>>(
30
31
  context: T, opts: PlanningFeedOptions = {}
31
32
  ): PlanningFeed => {
32
- const stores = planningStoresOf(context)
33
+ const stores = planningContextOf(context).stores()
33
34
  if (stores == null) {
34
35
  throw new SyntaxError('Planning feed needs appendPlanningStores(context)')
35
36
  }
36
37
  const service = context.service<PlanningClientService>(PLANNING_SERVICE)
37
38
  const facade = service.for(opts.scope)
39
+ const mirror = planningMirrorOf(stores)
38
40
 
39
41
  const state: PlanningFeedState = { connected: false, seeded: false, error: null }
40
42
  const report = (patch: Partial<PlanningFeedState>): void => {
@@ -47,7 +49,7 @@ export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C
47
49
  }
48
50
 
49
51
  const paged = opts.query?.size != null && opts.query.size > 0
50
- const where = opts.where ?? criteriaOf(opts.query)
52
+ const where = opts.where ?? queryHelper.criteriaOf(opts.query)
51
53
  const touched = new Set<string>()
52
54
  let seeding = 0
53
55
  let stopped = false
@@ -65,14 +67,14 @@ export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C
65
67
  return
66
68
  }
67
69
  if (paged) {
68
- await applyCards(stores, answer.items)
70
+ await mirror.applyCards(answer.items)
69
71
  } else {
70
- await syncCards(stores.cards, answer.items, where, { keep: touched })
72
+ await syncHelper.syncCards(stores.cards, answer.items, where, { keep: touched })
71
73
  }
72
74
  if (opts.links != null) {
73
75
  const links = await facade.relationships.list({ ...opts.links, size: opts.links.size ?? 0 })
74
76
  if (!stopped) {
75
- await syncLinks(stores.links, links.items, linkCriteriaOf(opts.links))
77
+ await syncHelper.syncLinks(stores.links, links.items, linkCriteriaOf(opts.links))
76
78
  }
77
79
  }
78
80
  report({ seeded: true, error: null })
@@ -95,7 +97,7 @@ export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C
95
97
  touched.add(event.card)
96
98
  }
97
99
  try {
98
- await applyCommitEvent(stores, event, facade)
100
+ await mirror.applyCommitEvent(event, facade)
99
101
  } catch (e) {
100
102
  log.warn('Planning feed apply failed', { card: event.card, error: e })
101
103
  }
@@ -0,0 +1,15 @@
1
+ import type { PlanningFacade, PlanningScope, Workcard, WorkcardModel } from '@owlmeans/planning'
2
+ import type { PlanningStores } from '../types.js'
3
+
4
+ /** The planning client of one context, without the `WithPlanningClient` mixin type. */
5
+ export interface PlanningContextHelper {
6
+ /** The facade of the context's planning client — `context.planning().for(scope)`. */
7
+ facade: (scope?: Partial<PlanningScope>) => PlanningFacade
8
+ /**
9
+ * The model of a card (read by id when given one), with the schema bundle loaded first so `can()` and
10
+ * `available()` answer from real flows. Built by `@owlmeans/planning`'s `modelOf(record, facade)`.
11
+ */
12
+ model: <R extends Workcard = Workcard>(card: R | string, scope?: Partial<PlanningScope>) => Promise<WorkcardModel<R>>
13
+ /** The stores `appendPlanningStores` registered, or `null` on a context that has none. */
14
+ stores: () => PlanningStores | null
15
+ }
package/src/helper.ts CHANGED
@@ -1,18 +1,35 @@
1
- import type { BasicConfig, BasicContext } from '@owlmeans/context'
1
+ import { memoHelper, type BasicConfig, type BasicContext } from '@owlmeans/context'
2
2
  import { PLANNING_SERVICE } from '@owlmeans/planning'
3
3
  import type { PlanningFacade, PlanningScope, Workcard, WorkcardModel } from '@owlmeans/planning'
4
- import type { PlanningClientService } from './types.js'
4
+ import type { PlanningContextHelper } from './helper/types.js'
5
+ import type { PlanningClientService, PlanningStores, WithPlanningStores } from './types.js'
5
6
 
6
- /** The facade of the context's planning client — `context.planning().for(scope)` without the mixin type. */
7
+ export const makePlanningContextHelper = (context: BasicContext<BasicConfig>): PlanningContextHelper => {
8
+ const facade = (scope?: Partial<PlanningScope>): PlanningFacade =>
9
+ context.service<PlanningClientService>(PLANNING_SERVICE).for(scope)
10
+
11
+ const model = async <R extends Workcard = Workcard>(
12
+ card: R | string, scope?: Partial<PlanningScope>
13
+ ): Promise<WorkcardModel<R>> => await facade(scope).model<R>(card)
14
+
15
+ const stores = (): PlanningStores | null => {
16
+ const ctx = context as Partial<WithPlanningStores>
17
+
18
+ return typeof ctx.planningStores === 'function' ? ctx.planningStores() : null
19
+ }
20
+
21
+ return { facade, model, stores }
22
+ }
23
+
24
+ /** The planning helper of a context — one per context. */
25
+ export const planningContextOf = memoHelper.oncePer(makePlanningContextHelper)
26
+
27
+ /** @deprecated compat:factory-refactor — use `planningContextOf(context).facade(…)` */
7
28
  export const planningOf = <C extends BasicConfig, T extends BasicContext<C>>(
8
29
  context: T, scope?: Partial<PlanningScope>
9
- ): PlanningFacade => context.service<PlanningClientService>(PLANNING_SERVICE).for(scope)
30
+ ): PlanningFacade => planningContextOf(context).facade(scope)
10
31
 
11
- /**
12
- * The model of a card (read by id when given one), with the schema bundle loaded first so `can()` and
13
- * `available()` answer from real flows. Named apart from `@owlmeans/planning`'s `modelOf(record,
14
- * facade)`, which it calls.
15
- */
32
+ /** @deprecated compat:factory-refactor — use `planningContextOf(context).model(…)` */
16
33
  export const planningModelOf = async <R extends Workcard = Workcard, C extends BasicConfig = BasicConfig, T extends BasicContext<C> = BasicContext<C>>(
17
34
  context: T, card: R | string, scope?: Partial<PlanningScope>
18
- ): Promise<WorkcardModel<R>> => await planningOf<C, T>(context, scope).model<R>(card)
35
+ ): Promise<WorkcardModel<R>> => await planningContextOf(context).model<R>(card, scope)
package/src/index.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  export type * from './types.js'
2
+ export type * from './events/types.js'
3
+ export type * from './helper/types.js'
4
+ export type * from './sync/types.js'
2
5
 
3
6
  export * from './consts.js'
4
- export * from './utils.js'
5
7
  export * from './client.js'
6
8
  export * from './facade.js'
7
9
  export * from './definitions.js'
8
10
  export * from './commits.js'
9
11
  export * from './stores.js'
12
+ export * from './sync.js'
10
13
  export * from './events.js'
11
14
  export * from './feed.js'
12
15
  export * from './helper.js'
package/src/stores.ts CHANGED
@@ -2,9 +2,10 @@ import type { BasicConfig, BasicContext } from '@owlmeans/context'
2
2
  import type { Relationship, Workcard } from '@owlmeans/planning'
3
3
  import type { Criteria } from '@owlmeans/resource'
4
4
  import { appendStateResource, type StateResourceAppend } from '@owlmeans/state'
5
- import { DEFAULT_STORE_ALIASES } from './utils.js'
5
+ import { DEFAULT_STORE_ALIASES } from './consts.js'
6
+ import { planningContextOf } from './helper.js'
7
+ import { syncHelper } from './sync.js'
6
8
  import type { PlanningStoreAliases, PlanningStores, SyncOptions, WithPlanningStores } from './types.js'
7
- import { recordHelper } from './utils/record.js'
8
9
 
9
10
  /**
10
11
  * Register the planning state mirror: ONE card store for every kind, a link store and a commit
@@ -34,47 +35,16 @@ export const appendPlanningStores = <C extends BasicConfig, T extends BasicConte
34
35
  return result
35
36
  }
36
37
 
37
- /** The stores `appendPlanningStores` registered, or `null` on a context that has none. */
38
- export const planningStoresOf = (context: BasicContext<any>): PlanningStores | null => {
39
- const ctx = context as Partial<WithPlanningStores>
40
38
 
41
- return typeof ctx.planningStores === 'function' ? ctx.planningStores() : null
42
- }
39
+ /** @deprecated compat:factory-refactor — use `planningContextOf(context).stores()` */
40
+ export const planningStoresOf = (context: BasicContext<any>): PlanningStores | null => planningContextOf(context).stores()
43
41
 
44
- /**
45
- * Make the store agree with an authoritative list WITHIN a scope: every card given is written
46
- * (unless the store holds a newer fold of it), and every card matching `where` that the list does
47
- * not name is dropped. Cards outside `where` are left alone — which is why this, and never
48
- * `replace()`, is how a list reaches the one shared card store.
49
- *
50
- * Unchanged cards are not rewritten, so a periodic re-seed wakes no subscriber.
51
- */
42
+ /** @deprecated compat:factory-refactor — use `syncHelper.syncCards(…)` */
52
43
  export const syncCards = async (
53
44
  store: PlanningStores['cards'], items: Workcard[], where?: Criteria<Workcard>, opts?: SyncOptions
54
- ): Promise<void> => {
55
- await dropUnnamed(store, recordHelper.idsOf(items), where, opts)
56
- for (const item of items) {
57
- await recordHelper.putCard(store, item)
58
- }
59
- }
45
+ ): Promise<void> => await syncHelper.syncCards(store, items, where, opts)
60
46
 
61
- /** {@link syncCards} for the link store. */
47
+ /** @deprecated compat:factory-refactor — use `syncHelper.syncLinks(…)` */
62
48
  export const syncLinks = async (
63
49
  store: PlanningStores['links'], items: Relationship[], where?: Criteria<Relationship>, opts?: SyncOptions
64
- ): Promise<void> => {
65
- await dropUnnamed(store, recordHelper.idsOf(items), where, opts)
66
- for (const item of items) {
67
- await recordHelper.putLink(store, item)
68
- }
69
- }
70
-
71
- const dropUnnamed = async <T extends Workcard | Relationship>(
72
- store: PlanningStores['cards'] | PlanningStores['links'], named: string[], where?: Criteria<T>, opts?: SyncOptions
73
- ): Promise<void> => {
74
- const keep = new Set([...named, ...(opts?.keep ?? [])])
75
- const scoped = await (store as PlanningStores['cards']).list(where as Criteria<Workcard>)
76
- const stale = recordHelper.idsOf(scoped.items).filter(id => !keep.has(id))
77
- if (stale.length > 0) {
78
- await store.purge({ id: { $in: stale } })
79
- }
80
- }
50
+ ): Promise<void> => await syncHelper.syncLinks(store, items, where, opts)
@@ -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,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
+ }