@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
package/src/feed.ts CHANGED
@@ -1,24 +1,27 @@
1
1
  import type { BasicConfig, BasicContext } from '@owlmeans/context'
2
- import { criteriaOf, PLANNING_SERVICE } from '@owlmeans/planning'
3
- import type { Relationship, RelationshipQuery, Unsubscribe } from '@owlmeans/planning'
2
+ import { logger } from '@owlmeans/log'
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 { clean } from './utils/record.js'
9
+ import { recordUtils } from './utils/record.js'
10
+
11
+ const log = logger('client-planning')
9
12
 
10
13
  const linkCriteriaOf = (query: RelationshipQuery): Criteria<Relationship> =>
11
- 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>
12
15
 
13
16
  /**
14
17
  * Keep the state mirror current: subscribe to commits FIRST, then seed from the list, then fold
15
18
  * every frame — and, with `refresh`, re-seed on an interval as the authoritative backstop to a
16
19
  * socket that can drop frames. React-free: a hook wraps it, a Node process uses it as it is.
17
20
  *
18
- * The seed is `syncCards` over `where` (default `criteriaOf(query)`), never `replace()`: the store
19
- * holds every kind, and a board's list must not drop the projects beside it. A card a frame wrote
20
- * while the list was in flight is kept even when the list does not name it yet. A PAGED seed only
21
- * 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.
22
25
  *
23
26
  * Without a socket opener the feed is a seed plus its refresh; `commits.wait` still long-polls.
24
27
  *
@@ -27,15 +30,19 @@ const linkCriteriaOf = (query: RelationshipQuery): Criteria<Relationship> =>
27
30
  export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C>>(
28
31
  context: T, opts: PlanningFeedOptions = {}
29
32
  ): PlanningFeed => {
30
- const stores = planningStoresOf(context)
33
+ const stores = planningContextOf(context).stores()
31
34
  if (stores == null) {
32
35
  throw new SyntaxError('Planning feed needs appendPlanningStores(context)')
33
36
  }
34
37
  const service = context.service<PlanningClientService>(PLANNING_SERVICE)
38
+ const { lifecycle } = service
39
+ const operation = lifecycle.capture()
35
40
  const facade = service.for(opts.scope)
41
+ const mirror = planningMirrorOf(stores)
36
42
 
37
43
  const state: PlanningFeedState = { connected: false, seeded: false, error: null }
38
44
  const report = (patch: Partial<PlanningFeedState>): void => {
45
+ if (!active()) return
39
46
  const next = { ...state, ...patch }
40
47
  if (next.connected === state.connected && next.seeded === state.seeded && next.error === state.error) {
41
48
  return
@@ -45,37 +52,39 @@ export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C
45
52
  }
46
53
 
47
54
  const paged = opts.query?.size != null && opts.query.size > 0
48
- const where = opts.where ?? criteriaOf(opts.query)
55
+ const where = opts.where ?? queryHelper.criteriaOf(opts.query)
49
56
  const touched = new Set<string>()
50
57
  let seeding = 0
51
58
  let stopped = false
59
+ const active = () => !stopped && operation.active()
52
60
  let unsubscribe: Unsubscribe | null = null
53
61
  let timer: ReturnType<typeof setInterval> | undefined
54
62
 
55
63
  const seed = async (): Promise<void> => {
56
- if (stopped) {
64
+ if (!active()) {
57
65
  return
58
66
  }
59
67
  seeding += 1
60
68
  try {
61
- const answer = await facade.cards.list(paged ? opts.query : { ...opts.query, size: 0 })
62
- if (stopped) {
69
+ const answer = await operation.wait(facade.cards.list(paged ? opts.query : { ...opts.query, size: 0 }))
70
+ if (!active()) {
63
71
  return
64
72
  }
65
73
  if (paged) {
66
- await applyCards(stores, answer.items)
74
+ await mirror.applyCards(answer.items, { lifecycle, operation })
67
75
  } else {
68
- await syncCards(stores.cards, answer.items, where, { keep: touched })
76
+ await lifecycle.mutate(operation, async () => await syncHelper.syncCards(stores.cards, answer.items, where, { keep: touched }))
69
77
  }
70
78
  if (opts.links != null) {
71
- const links = await facade.relationships.list({ ...opts.links, size: opts.links.size ?? 0 })
72
- if (!stopped) {
73
- await syncLinks(stores.links, links.items, linkCriteriaOf(opts.links))
79
+ const links = await operation.wait(facade.relationships.list({ ...opts.links, size: opts.links.size ?? 0 }))
80
+ if (active()) {
81
+ const criteria = linkCriteriaOf(opts.links)
82
+ await lifecycle.mutate(operation, async () => await syncHelper.syncLinks(stores.links, links.items, criteria))
74
83
  }
75
84
  }
76
85
  report({ seeded: true, error: null })
77
86
  } catch (e) {
78
- if (!stopped) {
87
+ if (active()) {
79
88
  report({ error: e as Error })
80
89
  }
81
90
  } finally {
@@ -88,17 +97,20 @@ export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C
88
97
 
89
98
  const ready = (async () => {
90
99
  try {
91
- const stop = await service.commits.subscribe(async event => {
100
+ const subscribing = Promise.resolve(service.commits.subscribe(async event => {
101
+ if (!active()) return
92
102
  if (seeding > 0) {
93
103
  touched.add(event.card)
94
104
  }
95
105
  try {
96
- await applyCommitEvent(stores, event, facade)
106
+ await mirror.applyCommitEvent(event, facade, { lifecycle, operation })
97
107
  } catch (e) {
98
- console.error('Planning feed apply error:', e)
108
+ if (active()) log.warn('Planning feed apply failed', { card: event.card, error: e })
99
109
  }
100
- }, opts.filter)
101
- if (stopped) {
110
+ }, opts.filter))
111
+ void subscribing.then(stop => { if (!active()) stop() }, () => {})
112
+ const stop = await operation.wait(subscribing)
113
+ if (!active()) {
102
114
  stop()
103
115
  return
104
116
  }
@@ -108,12 +120,13 @@ export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C
108
120
  report({ error: e as Error })
109
121
  }
110
122
  await seed()
111
- if (!stopped && opts.refresh != null && opts.refresh > 0) {
123
+ if (active() && opts.refresh != null && opts.refresh > 0) {
112
124
  timer = setInterval(() => { void seed() }, opts.refresh)
113
125
  }
114
126
  })()
115
127
 
116
- return {
128
+ let unregister: (() => void) | undefined
129
+ const feed: PlanningFeed = {
117
130
  get connected() { return state.connected },
118
131
  get seeded() { return state.seeded },
119
132
  get error() { return state.error },
@@ -121,11 +134,17 @@ export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C
121
134
  refresh: seed,
122
135
  stop: async () => {
123
136
  stopped = true
137
+ operation.cancel()
138
+ unregister?.()
124
139
  if (timer != null) {
125
140
  clearInterval(timer)
126
141
  }
127
142
  unsubscribe?.()
128
143
  unsubscribe = null
144
+ await lifecycle.drain()
145
+ operation.release()
129
146
  },
130
147
  }
148
+ unregister = service.registerFeed(feed)
149
+ return feed
131
150
  }
@@ -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,10 +1,22 @@
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'
5
+ export type * from './lifecycle/types.js'
6
+ export type * from './validation/types.js'
2
7
 
3
8
  export * from './consts.js'
4
9
  export * from './client.js'
5
10
  export * from './facade.js'
11
+ export * from './definitions.js'
6
12
  export * from './commits.js'
7
13
  export * from './stores.js'
14
+ export * from './sync.js'
8
15
  export * from './events.js'
9
16
  export * from './feed.js'
10
17
  export * from './helper.js'
18
+
19
+ export * from './resources.js'
20
+ export * from './resource-feed.js'
21
+ export * from './lifecycle.js'
22
+ export * from './validation.js'
@@ -0,0 +1,28 @@
1
+ /** One captured authenticated generation; cancellation does not undo a server write. */
2
+ export interface PlanningOperation {
3
+ key: string
4
+ signal: AbortSignal
5
+ active: () => boolean
6
+ check: () => void
7
+ wait: <T>(pending: Promise<T>) => Promise<T>
8
+ cancel: (reason?: Error) => void
9
+ release: () => void
10
+ }
11
+
12
+ /** Shared cancellation and the boundary between local mirror mutations and clearing. */
13
+ export interface PlanningClientLifecycle {
14
+ key: () => string
15
+ capture: (expected?: string) => PlanningOperation
16
+ run: <T>(work: (operation: PlanningOperation) => Promise<T>) => Promise<T>
17
+ /** Local store operations only: never hold this queue over an RPC or a socket opener. */
18
+ mutate: <T>(operation: PlanningOperation, work: () => Promise<T>) => Promise<T>
19
+ drain: () => Promise<void>
20
+ onInvalidate: (listener: () => void | Promise<void>) => () => void
21
+ close: () => Promise<void>
22
+ }
23
+
24
+ export interface PlanningClientLifecycleOptions {
25
+ scopeKey?: () => string | undefined
26
+ /** Runs in the mutation queue after admitted old writes have drained. */
27
+ clear?: () => Promise<void>
28
+ }
@@ -0,0 +1,123 @@
1
+ import { logger } from '@owlmeans/log'
2
+ import { PlanningError } from '@owlmeans/planning'
3
+ import type { PlanningClientLifecycle, PlanningClientLifecycleOptions, PlanningOperation } from './lifecycle/types.js'
4
+
5
+ const log = logger('client-planning')
6
+
7
+ export const makePlanningClientLifecycle = (opts: PlanningClientLifecycleOptions = {}): PlanningClientLifecycle => {
8
+ let identity = opts.scopeKey?.() ?? ''
9
+ let epoch = 0
10
+ let tail: Promise<void> = Promise.resolve()
11
+ let closing: Promise<void> | null = null
12
+ let blocked = false
13
+ let failed = false
14
+ let empty = false
15
+ let admissions = 0
16
+ const operations = new Set<AbortController>()
17
+ const listeners = new Set<() => void | Promise<void>>()
18
+ const changed = (): PlanningError => new PlanningError('client:scope-changed')
19
+
20
+ const cancel = (): void => {
21
+ for (const controller of operations) controller.abort(changed())
22
+ operations.clear()
23
+ }
24
+ const invalidate = (explicit = true): Promise<void> => {
25
+ // Changing a credential cannot recover a failed clear. Only an awaited explicit retry can.
26
+ if (!explicit && failed) { epoch++; cancel(); return Promise.resolve() }
27
+ if (explicit && closing != null && blocked) return closing
28
+ // An explicit close rejects admission. A newly observed identity already names the new
29
+ // credential: its RPCs may start, but its local writes queue behind this generation's clear.
30
+ blocked = explicit || blocked
31
+ epoch++
32
+ cancel()
33
+ const admitted = admissions
34
+ const previous = closing
35
+ let resolve!: () => void
36
+ let reject!: (error: unknown) => void
37
+ const attempt = new Promise<void>((yes, no) => { resolve = yes; reject = no })
38
+ closing = attempt
39
+ // Reserve clearing before invalidation listeners can start work. Each observed identity
40
+ // change reserves its own clear, including changes while an earlier close is still draining.
41
+ const clearing = tail.then(async () => { await opts.clear?.() }).catch(error => {
42
+ blocked = true; failed = true; cancel(); throw error
43
+ })
44
+ tail = clearing.catch(() => {})
45
+ const stopping = [...listeners].map(async listener => { await listener() })
46
+ void Promise.all([clearing, ...stopping, ...(previous != null ? [previous] : [])]).then(() => {
47
+ if (closing === attempt) { blocked = false; failed = false; closing = null; empty = admissions === admitted }
48
+ resolve()
49
+ }, error => {
50
+ if (closing === attempt) { blocked = true; failed = true; closing = null; cancel() }
51
+ reject(error)
52
+ })
53
+ // key() can invalidate without an awaiting caller; still observe and report cleanup failures.
54
+ void attempt.catch(error => log.error('Planning scope cleanup failed', error))
55
+ return attempt
56
+ }
57
+
58
+ const key = (): string => {
59
+ const current = opts.scopeKey?.() ?? ''
60
+ if (current !== identity) {
61
+ identity = current
62
+ // An awaited close already drained and emptied this generation. Adopt the next credential
63
+ // synchronously when no request/feed was admitted since, without a redundant invalidation.
64
+ if (empty && closing == null && !blocked && !failed) epoch++
65
+ else void invalidate(false)
66
+ }
67
+ return `${epoch}:${identity}`
68
+ }
69
+
70
+ const capture = (expected?: string): PlanningOperation => {
71
+ const captured = key()
72
+ if (blocked) throw changed()
73
+ if (expected != null && expected !== captured) throw changed()
74
+ empty = false
75
+ admissions++
76
+ const controller = new AbortController()
77
+ operations.add(controller)
78
+ const active = (): boolean => key() === captured && !blocked && !controller.signal.aborted
79
+ const check = (): void => {
80
+ if (key() !== captured || blocked) throw changed()
81
+ if (controller.signal.aborted) throw controller.signal.reason
82
+ }
83
+ const wait = async <T>(pending: Promise<T>): Promise<T> => {
84
+ let abort: (() => void) | undefined
85
+ try {
86
+ // Observe even an already-cancelled operation's pending promise; a late RPC rejection
87
+ // must never become unhandled after its caller has left this generation.
88
+ const cancelled = new Promise<never>((_, reject) => {
89
+ abort = () => reject(controller.signal.reason ?? changed())
90
+ controller.signal.addEventListener('abort', abort, { once: true })
91
+ if (!active()) abort()
92
+ })
93
+ const result = await Promise.race([pending, cancelled])
94
+ check()
95
+ return result
96
+ } catch (error) { check(); throw error }
97
+ finally { if (abort != null) controller.signal.removeEventListener('abort', abort) }
98
+ }
99
+ return { key: captured, signal: controller.signal, active, check, wait,
100
+ cancel: reason => { controller.abort(reason ?? changed()) }, release: () => { operations.delete(controller) } }
101
+ }
102
+
103
+ return {
104
+ key, capture,
105
+ run: async work => {
106
+ const operation = capture()
107
+ try { const result = await work(operation); operation.check(); return result } finally { operation.release() }
108
+ },
109
+ mutate: async (operation, work) => {
110
+ const writing = tail.then(async () => {
111
+ operation.check()
112
+ const result = await work()
113
+ operation.check()
114
+ return result
115
+ })
116
+ tail = writing.then(() => {}, () => {})
117
+ return await writing
118
+ },
119
+ drain: async () => { await tail },
120
+ onInvalidate: listener => { listeners.add(listener); return () => { listeners.delete(listener) } },
121
+ close: async () => { identity = opts.scopeKey?.() ?? ''; await invalidate() },
122
+ }
123
+ }
@@ -0,0 +1,79 @@
1
+ import type { BasicConfig, BasicContext } from '@owlmeans/context'
2
+ import { mentionHelper, PLANNING_SERVICE, type PlanningRecord } from '@owlmeans/planning'
3
+ import type { ListResult } from '@owlmeans/resource'
4
+ import { planningContextOf } from './helper.js'
5
+ import type { PlanningClientService, PlanningFeed, PlanningFeedState, PlanningResourceFeedOptions } from './types.js'
6
+
7
+ /** Refresh separate resources into the stock stores; a stopped/changed-tenant feed cannot publish. */
8
+ export const makePlanningResourceFeed = <C extends BasicConfig, T extends BasicContext<C>>(
9
+ context: T, opts: PlanningResourceFeedOptions = {}
10
+ ): PlanningFeed => {
11
+ const stores = planningContextOf(context).stores()
12
+ if (stores == null) throw new SyntaxError('Planning feed needs appendPlanningStores(context)')
13
+ const service = context.service<PlanningClientService>(PLANNING_SERVICE)
14
+ const { lifecycle } = service
15
+ const operation = lifecycle.capture()
16
+ const active = () => !stopped && operation.active()
17
+ const facade = service.for(opts.scope)
18
+ const state: PlanningFeedState = { connected: false, seeded: false, error: null }
19
+ let stopped = false
20
+ let pending: Promise<void> | undefined
21
+ let timer: ReturnType<typeof setInterval> | undefined
22
+ const refresh = async (): Promise<void> => {
23
+ if (!active()) return
24
+ if (pending != null) return await pending
25
+ pending = (async () => {
26
+ try {
27
+ for (const name of ['assignees', 'teams', 'comments', 'mentions'] as const) {
28
+ const query = opts[name]
29
+ if (query == null) continue
30
+ const store = stores[name]
31
+ const { page: _page, size: _size, sort: _sort, ids, nickname, authentication, ...where } = query as Record<string, any>
32
+ const matches = { ...where, ...(ids != null ? { id: { $in: ids } } : {}),
33
+ ...(name === 'assignees' && where.retired === false ? { retired: { $ne: true } } : {}),
34
+ ...(nickname != null ? { nicknameKey: mentionHelper.nicknameKey(nickname) } : {}),
35
+ ...(authentication != null ? { 'authentication.provider': authentication.provider, 'authentication.externalId': authentication.externalId } : {}),
36
+ ...(opts.scope?.entityId != null ? { entityId: opts.scope.entityId } : {}) }
37
+ // Capture prior versions before the request: a local mutation/new row arriving while
38
+ // the seed is in flight must survive an older snapshot's absence.
39
+ const snapshot = (await operation.wait<ListResult<PlanningRecord>>(store.list(matches as never, { size: 0 }))).items
40
+ operation.check()
41
+ const rows = await operation.wait<ListResult<PlanningRecord>>(facade[name].list(query as never))
42
+ if (!active()) return
43
+ const named = new Set(rows.items.map(row => row.id))
44
+ await lifecycle.mutate(operation, async () => {
45
+ if (!(query.size != null && query.size > 0)) {
46
+ for (const row of snapshot) if (!named.has(row.id)) {
47
+ const current = await store.load(row.id!)
48
+ if (current?.version === row.version) await store.delete(row.id!)
49
+ }
50
+ }
51
+ for (const row of rows.items as PlanningRecord[]) {
52
+ const previous = await store.load(row.id!)
53
+ if (previous == null || previous.version <= row.version) await store.save(row as never)
54
+ }
55
+ })
56
+ }
57
+ if (active()) Object.assign(state, { seeded: true, error: null })
58
+ } catch (error) { if (active()) state.error = error as Error }
59
+ if (active()) opts.onChange?.({ ...state })
60
+ })()
61
+ try { await pending } finally { pending = undefined }
62
+ }
63
+ const ready = refresh().then(() => {
64
+ if (active() && (opts.refresh ?? 5000) > 0) timer = setInterval(() => { void refresh() }, opts.refresh ?? 5000)
65
+ })
66
+ let unregister: (() => void) | undefined
67
+ const feed: PlanningFeed = {
68
+ get connected() { return state.connected }, get seeded() { return state.seeded }, get error() { return state.error },
69
+ ready, refresh,
70
+ stop: async () => {
71
+ stopped = true; operation.cancel(); unregister?.()
72
+ if (timer != null) clearInterval(timer)
73
+ await lifecycle.drain()
74
+ operation.release()
75
+ },
76
+ }
77
+ unregister = service.registerFeed(feed)
78
+ return feed
79
+ }
@@ -0,0 +1,84 @@
1
+ import type { BasicContext, BasicConfig } from '@owlmeans/context'
2
+ import { planningReplyHelper, PlanningUnsupported, WorkcardNotFound } from '@owlmeans/planning'
3
+ import type { Assignee, Team, Comment, CommentMention, PlanningResourceProtocols, PlanningProtocols, PlanningResourceCommand, PlanningResourceFacade, PlanningRecord, PlanningResourceReply } from '@owlmeans/planning'
4
+ import type { RemoteFacadeOptions } from './types.js'
5
+ import type { ListResult } from '@owlmeans/resource'
6
+ import { makePlanningClientLifecycle } from './lifecycle.js'
7
+
8
+ /** The same auxiliary facade in browsers and Node clients, with versioned stock state mirrors. */
9
+ export const makeRemoteResourceFacade = <C extends BasicConfig, T extends BasicContext<C>>(
10
+ context: T, protocols: PlanningProtocols, opts: RemoteFacadeOptions
11
+ ): PlanningResourceFacade => {
12
+ const lifecycle = opts.lifecycle ?? makePlanningClientLifecycle({ scopeKey: opts.scopeKey })
13
+ const tree = (name: 'assignees' | 'teams' | 'comments' | 'mentions') => {
14
+ const value = protocols[name]
15
+ if (value == null) throw new PlanningUnsupported(`client:${name}`)
16
+ return value as unknown as PlanningResourceProtocols
17
+ }
18
+ const mirror = async (name: 'assignees' | 'teams' | 'comments' | 'mentions', row: PlanningRecord): Promise<void> => {
19
+ const store = opts.stores?.()?.[name]
20
+ if (store == null || row.id == null) return
21
+ const known = await store.load(row.id)
22
+ if (known == null || known.version <= row.version) await store.save(row as never)
23
+ }
24
+ const get = async (name: 'assignees' | 'teams' | 'comments' | 'mentions', id: string) => lifecycle.run(async operation => {
25
+ const row = planningReplyHelper.hydrate<PlanningRecord>(await operation.wait(context.entrypoint(tree(name).get).call({ params: { id }, timeout: opts.timeout, signal: operation.signal })))
26
+ await lifecycle.mutate(operation, async () => await mirror(name, row))
27
+ return row
28
+ })
29
+ const load = async (name: 'assignees' | 'teams' | 'comments' | 'mentions', id: string) => {
30
+ try { return await get(name, id) } catch (error) { if (error instanceof WorkcardNotFound) return null; throw error }
31
+ }
32
+ const list = async (name: 'assignees' | 'teams' | 'comments' | 'mentions', query?: object) => lifecycle.run(async operation => {
33
+ const rows = planningReplyHelper.hydrate<ListResult<PlanningRecord>>(await operation.wait(context.entrypoint(tree(name).list).call({ query: { query: JSON.stringify(query ?? {}) }, timeout: opts.timeout, signal: operation.signal })))
34
+ return rows
35
+ })
36
+ const write = async (name: 'assignees' | 'teams' | 'comments' | 'mentions', body: PlanningResourceCommand) => lifecycle.run(async operation => {
37
+ const answer = planningReplyHelper.hydrate<PlanningResourceReply>(await operation.wait(context.entrypoint(tree(name).write).call({ body, timeout: opts.timeout, signal: operation.signal })))
38
+ await lifecycle.mutate(operation, async () => {
39
+ if (answer != null && !Array.isArray(answer)) await mirror(name, answer)
40
+ if (name === 'mentions' && Array.isArray(answer)) for (const row of answer) if (typeof row !== 'string') await mirror(name, row)
41
+ if (body.action === 'remove' && body.id != null) await opts.stores?.()?.[name].delete(body.id)
42
+ })
43
+ return answer
44
+ })
45
+ return {
46
+ assignees: {
47
+ get: async id => await get('assignees', id) as Assignee,
48
+ load: async id => await load('assignees', id) as Assignee | null,
49
+ list: async query => await list('assignees', query) as ListResult<Assignee>,
50
+ create: async draft => await write('assignees', { action: 'create', draft }) as Assignee,
51
+ update: async (id, draft, options) => await write('assignees', { action: 'update', id, draft, ...options }) as Assignee,
52
+ retire: async (id, options) => await write('assignees', { action: 'retire', id, ...options }) as Assignee,
53
+ },
54
+ teams: {
55
+ get: async id => await get('teams', id) as Team,
56
+ load: async id => await load('teams', id) as Team | null,
57
+ list: async query => await list('teams', query) as ListResult<Team>,
58
+ create: async draft => await write('teams', { action: 'create', draft }) as Team,
59
+ update: async (id, draft, options) => await write('teams', { action: 'update', id, draft, ...options }) as Team,
60
+ remove: async (id, options) => { await write('teams', { action: 'remove', id, ...options }) },
61
+ members: async id => await write('teams', { action: 'members', id }) as Assignee[],
62
+ projects: async id => await write('teams', { action: 'projects', id }) as string[],
63
+ assignees: async id => await write('teams', { action: 'assignees', id }) as Assignee[],
64
+ addMember: async (id, assignee) => { await write('teams', { action: 'addMember', id, assignee }) },
65
+ removeMember: async (id, assignee) => { await write('teams', { action: 'removeMember', id, assignee }) },
66
+ attach: async (id, project) => { await write('teams', { action: 'attach', id, project }) },
67
+ detach: async (id, project) => { await write('teams', { action: 'detach', id, project }) },
68
+ },
69
+ comments: {
70
+ get: async id => await get('comments', id) as Comment,
71
+ load: async id => await load('comments', id) as Comment | null,
72
+ list: async query => await list('comments', query) as ListResult<Comment>,
73
+ create: async draft => await write('comments', { action: 'create', draft }) as Comment,
74
+ update: async (id, draft, options) => await write('comments', { action: 'update', id, draft, ...options }) as Comment,
75
+ remove: async (id, options) => { await write('comments', { action: 'remove', id, ...options }) },
76
+ },
77
+ mentions: {
78
+ get: async id => await get('mentions', id) as CommentMention,
79
+ load: async id => await load('mentions', id) as CommentMention | null,
80
+ list: async query => await list('mentions', query) as ListResult<CommentMention>,
81
+ rebuild: async id => await write('mentions', { action: 'rebuild', id }) as CommentMention[],
82
+ },
83
+ }
84
+ }
package/src/stores.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  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
- import { appendStateResource } from '@owlmeans/state'
5
- import type { StateResourceAppend } from '@owlmeans/state'
4
+ import { appendStateResource, type StateResourceAppend } from '@owlmeans/state'
6
5
  import { DEFAULT_STORE_ALIASES } from './consts.js'
6
+ import { planningContextOf } from './helper.js'
7
+ import { syncHelper } from './sync.js'
7
8
  import type { PlanningStoreAliases, PlanningStores, SyncOptions, WithPlanningStores } from './types.js'
8
- import { idsOf, putCard, putLink } from './utils/record.js'
9
9
 
10
10
  /**
11
11
  * Register the planning state mirror: ONE card store for every kind, a link store and a commit
@@ -23,10 +23,12 @@ export const appendPlanningStores = <C extends BasicConfig, T extends BasicConte
23
23
  ctx = appendStateResource<C, typeof ctx, Relationship>(ctx, names.links)
24
24
  ctx = appendStateResource<C, typeof ctx>(ctx, names.commits)
25
25
 
26
+ for (const name of [names.assignees, names.teams, names.comments, names.mentions]) ctx = appendStateResource(ctx, name as any)
26
27
  const result = ctx as T & WithPlanningStores & StateResourceAppend
27
28
  if (result.planningStores == null) {
28
29
  result.planningStores = () => ({
29
30
  cards: result.getStateResource(names.cards),
31
+ assignees: result.getStateResource(names.assignees), teams: result.getStateResource(names.teams), comments: result.getStateResource(names.comments), mentions: result.getStateResource(names.mentions),
30
32
  links: result.getStateResource(names.links),
31
33
  commits: result.getStateResource(names.commits),
32
34
  })
@@ -35,47 +37,16 @@ export const appendPlanningStores = <C extends BasicConfig, T extends BasicConte
35
37
  return result
36
38
  }
37
39
 
38
- /** The stores `appendPlanningStores` registered, or `null` on a context that has none. */
39
- export const planningStoresOf = (context: BasicContext<any>): PlanningStores | null => {
40
- const ctx = context as Partial<WithPlanningStores>
41
40
 
42
- return typeof ctx.planningStores === 'function' ? ctx.planningStores() : null
43
- }
41
+ /** @deprecated compat:factory-refactor — use `planningContextOf(context).stores()` */
42
+ export const planningStoresOf = (context: BasicContext<any>): PlanningStores | null => planningContextOf(context).stores()
44
43
 
45
- /**
46
- * Make the store agree with an authoritative list WITHIN a scope: every card given is written
47
- * (unless the store holds a newer fold of it), and every card matching `where` that the list does
48
- * not name is dropped. Cards outside `where` are left alone — which is why this, and never
49
- * `replace()`, is how a list reaches the one shared card store.
50
- *
51
- * Unchanged cards are not rewritten, so a periodic re-seed wakes no subscriber.
52
- */
44
+ /** @deprecated compat:factory-refactor — use `syncHelper.syncCards(…)` */
53
45
  export const syncCards = async (
54
46
  store: PlanningStores['cards'], items: Workcard[], where?: Criteria<Workcard>, opts?: SyncOptions
55
- ): Promise<void> => {
56
- await dropUnnamed(store, idsOf(items), where, opts)
57
- for (const item of items) {
58
- await putCard(store, item)
59
- }
60
- }
47
+ ): Promise<void> => await syncHelper.syncCards(store, items, where, opts)
61
48
 
62
- /** {@link syncCards} for the link store. */
49
+ /** @deprecated compat:factory-refactor — use `syncHelper.syncLinks(…)` */
63
50
  export const syncLinks = async (
64
51
  store: PlanningStores['links'], items: Relationship[], where?: Criteria<Relationship>, opts?: SyncOptions
65
- ): Promise<void> => {
66
- await dropUnnamed(store, idsOf(items), where, opts)
67
- for (const item of items) {
68
- await putLink(store, item)
69
- }
70
- }
71
-
72
- const dropUnnamed = async <T extends Workcard | Relationship>(
73
- store: PlanningStores['cards'] | PlanningStores['links'], named: string[], where?: Criteria<T>, opts?: SyncOptions
74
- ): Promise<void> => {
75
- const keep = new Set([...named, ...(opts?.keep ?? [])])
76
- const scoped = await (store as PlanningStores['cards']).list(where as Criteria<Workcard>)
77
- const stale = idsOf(scoped.items).filter(id => !keep.has(id))
78
- if (stale.length > 0) {
79
- await store.purge({ id: { $in: stale } })
80
- }
81
- }
52
+ ): Promise<void> => await syncHelper.syncLinks(store, items, where, opts)