@owlmeans/client-planning 0.1.18-rc.8 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -11
- package/agent-meta/manifest.json +62 -4
- package/agent-meta/skills/client-planning/SKILL.md +60 -78
- package/agent-meta/skills/client-planning/parts/aux-mirrors.md +61 -0
- package/agent-meta/skills/client-planning/parts/mirror.md +67 -0
- package/agent-meta/skills/client-planning/parts/validation.md +60 -0
- package/build/client.d.ts +1 -1
- package/build/client.d.ts.map +1 -1
- package/build/client.js +66 -14
- package/build/client.js.map +1 -1
- package/build/commits.d.ts +2 -13
- package/build/commits.d.ts.map +1 -1
- package/build/commits.js +190 -132
- package/build/commits.js.map +1 -1
- package/build/consts.d.ts +4 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +10 -4
- package/build/consts.js.map +1 -1
- package/build/definitions.d.ts +13 -0
- package/build/definitions.d.ts.map +1 -0
- package/build/definitions.js +70 -0
- package/build/definitions.js.map +1 -0
- package/build/events/types.d.ts +34 -0
- package/build/events/types.d.ts.map +1 -0
- package/build/events/types.js +2 -0
- package/build/events/types.js.map +1 -0
- package/build/events.d.ts +10 -21
- package/build/events.d.ts.map +1 -1
- package/build/events.js +104 -102
- package/build/events.js.map +1 -1
- package/build/facade.d.ts.map +1 -1
- package/build/facade.js +86 -49
- package/build/facade.js.map +1 -1
- package/build/feed.d.ts +4 -4
- package/build/feed.d.ts.map +1 -1
- package/build/feed.js +50 -27
- package/build/feed.js.map +1 -1
- package/build/helper/types.d.ts +15 -0
- package/build/helper/types.d.ts.map +1 -0
- package/build/helper/types.js +2 -0
- package/build/helper/types.js.map +1 -0
- package/build/helper.d.ts +7 -7
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +16 -8
- package/build/helper.js.map +1 -1
- package/build/index.d.ts +11 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +6 -0
- package/build/index.js.map +1 -1
- package/build/lifecycle/types.d.ts +27 -0
- package/build/lifecycle/types.d.ts.map +1 -0
- package/build/lifecycle/types.js +2 -0
- package/build/lifecycle/types.js.map +1 -0
- package/build/lifecycle.d.ts +3 -0
- package/build/lifecycle.d.ts.map +1 -0
- package/build/lifecycle.js +157 -0
- package/build/lifecycle.js.map +1 -0
- package/build/resource-feed.d.ts +5 -0
- package/build/resource-feed.d.ts.map +1 -0
- package/build/resource-feed.js +97 -0
- package/build/resource-feed.js.map +1 -0
- package/build/resources.d.ts +6 -0
- package/build/resources.d.ts.map +1 -0
- package/build/resources.js +93 -0
- package/build/resources.js.map +1 -0
- package/build/stores.d.ts +4 -11
- package/build/stores.d.ts.map +1 -1
- package/build/stores.js +11 -35
- package/build/stores.js.map +1 -1
- package/build/sync/types.d.ts +18 -0
- package/build/sync/types.d.ts.map +1 -0
- package/build/sync/types.js +2 -0
- package/build/sync/types.js.map +1 -0
- package/build/sync.d.ts +4 -0
- package/build/sync.d.ts.map +1 -0
- package/build/sync.js +26 -0
- package/build/sync.js.map +1 -0
- package/build/types.d.ts +48 -3
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +13 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/record/types.d.ts +28 -0
- package/build/utils/record/types.d.ts.map +1 -0
- package/build/utils/record/types.js +2 -0
- package/build/utils/record/types.js.map +1 -0
- package/build/utils/record.d.ts +3 -24
- package/build/utils/record.d.ts.map +1 -1
- package/build/utils/record.js +64 -70
- package/build/utils/record.js.map +1 -1
- package/build/validation/consts.d.ts +23 -0
- package/build/validation/consts.d.ts.map +1 -0
- package/build/validation/consts.js +289 -0
- package/build/validation/consts.js.map +1 -0
- package/build/validation/types.d.ts +17 -0
- package/build/validation/types.d.ts.map +1 -0
- package/build/validation/types.js +2 -0
- package/build/validation/types.js.map +1 -0
- package/build/validation.d.ts +10 -0
- package/build/validation.d.ts.map +1 -0
- package/build/validation.js +171 -0
- package/build/validation.js.map +1 -0
- package/package.json +20 -18
- package/src/client.ts +58 -15
- package/src/commits.ts +152 -184
- package/src/consts.ts +12 -4
- package/src/definitions.ts +93 -0
- package/src/events/types.ts +35 -0
- package/src/events.ts +118 -108
- package/src/facade.ts +87 -55
- package/src/feed.ts +47 -28
- package/src/helper/types.ts +15 -0
- package/src/helper.ts +27 -10
- package/src/index.ts +12 -0
- package/src/lifecycle/types.ts +28 -0
- package/src/lifecycle.ts +123 -0
- package/src/resource-feed.ts +79 -0
- package/src/resources.ts +84 -0
- package/src/stores.ts +11 -40
- package/src/sync/types.ts +18 -0
- package/src/sync.ts +40 -0
- package/src/types.local.ts +14 -0
- package/src/types.ts +53 -5
- package/src/utils/record/types.ts +27 -0
- package/src/utils/record.ts +68 -71
- package/src/validation/consts.ts +289 -0
- package/src/validation/types.ts +18 -0
- package/src/validation.ts +140 -0
- package/tests/app.spec.ts +72 -0
- package/tests/context.ts +46 -15
- package/tests/csp.spec.ts +119 -0
- package/tests/definitions.spec.ts +74 -0
- package/tests/draft7.spec.ts +13 -0
- package/tests/events.spec.ts +13 -9
- package/tests/facade.spec.ts +2 -1
- package/tests/lifecycle/context.ts +29 -0
- package/tests/lifecycle-close.spec.ts +103 -0
- package/tests/lifecycle-commits.spec.ts +92 -0
- package/tests/lifecycle-definitions.spec.ts +28 -0
- package/tests/lifecycle-feeds.spec.ts +92 -0
- package/tests/lifecycle-sockets.spec.ts +76 -0
- package/tests/replies.spec.ts +81 -0
- package/tests/resources.spec.ts +116 -0
- package/tests/stores.spec.ts +13 -11
- 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 {
|
|
3
|
-
import type
|
|
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 {
|
|
6
|
-
import {
|
|
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 {
|
|
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
|
|
19
|
-
* holds every kind, and a board's list must not drop the projects beside it. A
|
|
20
|
-
* while the list was in flight is kept even when the list does not name it yet. A
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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(
|
|
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 (
|
|
73
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
106
|
+
await mirror.applyCommitEvent(event, facade, { lifecycle, operation })
|
|
97
107
|
} catch (e) {
|
|
98
|
-
|
|
108
|
+
if (active()) log.warn('Planning feed apply failed', { card: event.card, error: e })
|
|
99
109
|
}
|
|
100
|
-
}, opts.filter)
|
|
101
|
-
if (
|
|
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 (
|
|
123
|
+
if (active() && opts.refresh != null && opts.refresh > 0) {
|
|
112
124
|
timer = setInterval(() => { void seed() }, opts.refresh)
|
|
113
125
|
}
|
|
114
126
|
})()
|
|
115
127
|
|
|
116
|
-
|
|
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
|
|
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 {
|
|
4
|
+
import type { PlanningContextHelper } from './helper/types.js'
|
|
5
|
+
import type { PlanningClientService, PlanningStores, WithPlanningStores } from './types.js'
|
|
5
6
|
|
|
6
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -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
|
+
}
|
package/src/resources.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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)
|