@owlmeans/client-planning 0.1.18-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/client-planning/SKILL.md +212 -0
- package/build/client.d.ts +26 -0
- package/build/client.d.ts.map +1 -0
- package/build/client.js +120 -0
- package/build/client.js.map +1 -0
- package/build/commits.d.ts +17 -0
- package/build/commits.d.ts.map +1 -0
- package/build/commits.js +205 -0
- package/build/commits.js.map +1 -0
- package/build/consts.d.ts +27 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +29 -0
- package/build/consts.js.map +1 -0
- package/build/events.d.ts +27 -0
- package/build/events.d.ts.map +1 -0
- package/build/events.js +109 -0
- package/build/events.js.map +1 -0
- package/build/facade.d.ts +19 -0
- package/build/facade.d.ts.map +1 -0
- package/build/facade.js +130 -0
- package/build/facade.js.map +1 -0
- package/build/feed.d.ts +18 -0
- package/build/feed.d.ts.map +1 -0
- package/build/feed.js +123 -0
- package/build/feed.js.map +1 -0
- package/build/helper.d.ts +11 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +10 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +10 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +9 -0
- package/build/index.js.map +1 -0
- package/build/stores.d.ts +28 -0
- package/build/stores.d.ts.map +1 -0
- package/build/stores.js +61 -0
- package/build/stores.js.map +1 -0
- package/build/types.d.ts +147 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/record.d.ts +25 -0
- package/build/utils/record.d.ts.map +1 -0
- package/build/utils/record.js +72 -0
- package/build/utils/record.js.map +1 -0
- package/package.json +55 -0
- package/src/client.ts +153 -0
- package/src/commits.ts +247 -0
- package/src/consts.ts +38 -0
- package/src/events.ts +121 -0
- package/src/facade.ts +169 -0
- package/src/feed.ts +131 -0
- package/src/helper.ts +18 -0
- package/src/index.ts +10 -0
- package/src/stores.ts +81 -0
- package/src/types.ts +163 -0
- package/src/utils/record.ts +91 -0
- package/tests/commits.spec.ts +67 -0
- package/tests/context.ts +301 -0
- package/tests/events.spec.ts +95 -0
- package/tests/facade.spec.ts +83 -0
- package/tests/feed.spec.ts +39 -0
- package/tests/stores.spec.ts +67 -0
- package/tests/tsconfig.json +17 -0
- package/tsconfig.json +15 -0
package/src/facade.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import {
|
|
3
|
+
CommitFailed, CommitState, DEFAULT_COMMIT_TIMEOUT, PlanningError, PlanningUnsupported, WorkcardNotFound,
|
|
4
|
+
encodeRelationshipQuery, encodeSpecificationQuery, encodeSummaryQuery, encodeTransitionQuery,
|
|
5
|
+
encodeWorkcardQuery, modelOf,
|
|
6
|
+
} from '@owlmeans/planning'
|
|
7
|
+
import type {
|
|
8
|
+
PlanningFacade, PlanningProtocols, PlanningScope, TransitionReceipt,
|
|
9
|
+
TransitionReceiptView, Workcard, WorkcardModel,
|
|
10
|
+
} from '@owlmeans/planning'
|
|
11
|
+
import { applyReceipt } from './events.js'
|
|
12
|
+
import type { RemoteFacadeOptions } from './types.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A {@link PlanningFacade} over the remote protocol tree — every method is one entrypoint call.
|
|
16
|
+
*
|
|
17
|
+
* It is the same interface a server facade implements, so a model (`modelOf`) and everything
|
|
18
|
+
* built on the facade run unchanged in a browser or a Node client. Three things differ, and all
|
|
19
|
+
* three are the server's to decide:
|
|
20
|
+
*
|
|
21
|
+
* - the scope is advisory — the server takes the entity and the actor from the credential, and the
|
|
22
|
+
* execution's own `actor` is never sent;
|
|
23
|
+
* - `execute({ wait: true })` never holds the POST: the receipt comes back at once and the commit is
|
|
24
|
+
* awaited through `commits.wait` (subscribe, then long-poll), so a lost response cannot leave a
|
|
25
|
+
* caller unsure whether the transition was appended;
|
|
26
|
+
* - `cards.count` is a one-row list read for its `total` — the tree declares no count route.
|
|
27
|
+
*/
|
|
28
|
+
export const makeRemoteFacade = <C extends BasicConfig, T extends BasicContext<C>>(
|
|
29
|
+
context: T, protocols: PlanningProtocols, scope: Partial<PlanningScope>, opts: RemoteFacadeOptions
|
|
30
|
+
): PlanningFacade => {
|
|
31
|
+
const timeout = opts.timeout
|
|
32
|
+
const { commits } = opts
|
|
33
|
+
|
|
34
|
+
const receiptOf = (view: TransitionReceiptView): TransitionReceipt => {
|
|
35
|
+
const id = view.transition.id
|
|
36
|
+
if (id == null) {
|
|
37
|
+
throw new PlanningError('malformed:receipt-without-transition')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
transition: view.transition,
|
|
42
|
+
...(view.card !== undefined ? { card: view.card } : {}),
|
|
43
|
+
committed: async waitOpts => {
|
|
44
|
+
const { state, error } = view.transition.commit
|
|
45
|
+
if (state === CommitState.Failed) {
|
|
46
|
+
throw new CommitFailed(error ?? id)
|
|
47
|
+
}
|
|
48
|
+
if (state === CommitState.Committed && view.card !== undefined) {
|
|
49
|
+
return view.card
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return await commits.wait(id, { timeout: waitOpts?.timeout ?? DEFAULT_COMMIT_TIMEOUT })
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const facade: PlanningFacade = {
|
|
58
|
+
scope: { ...scope, entityId: scope.entityId ?? '' },
|
|
59
|
+
|
|
60
|
+
schemas: opts.schemas,
|
|
61
|
+
|
|
62
|
+
cards: {
|
|
63
|
+
get: async id => await context.entrypoint(protocols.card.get).call({ params: { id }, timeout }),
|
|
64
|
+
|
|
65
|
+
load: async id => {
|
|
66
|
+
try {
|
|
67
|
+
return await facade.cards.get(id)
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e instanceof WorkcardNotFound) {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
throw e
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
list: async query => await context.entrypoint(protocols.card.list).call({
|
|
77
|
+
query: encodeWorkcardQuery(query), timeout,
|
|
78
|
+
}),
|
|
79
|
+
|
|
80
|
+
count: async query => {
|
|
81
|
+
const { page: _page, size: _size, sort: _sort, ...where } = query ?? {}
|
|
82
|
+
const answer = await context.entrypoint(protocols.card.list).call({
|
|
83
|
+
query: encodeWorkcardQuery({ ...where, page: 0, size: 1 }), timeout,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return answer.total
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
summary: async (parents, query) => await context.entrypoint(protocols.card.summary).call({
|
|
90
|
+
query: encodeSummaryQuery({ ...query, parents }), timeout,
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
specifications: {
|
|
95
|
+
current: async (parent, category) => {
|
|
96
|
+
const answer = await context.entrypoint(protocols.card.specifications).call({
|
|
97
|
+
params: { id: parent }, query: encodeSpecificationQuery({ category }), timeout,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return answer.items[0] ?? null
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
list: async (parent, query) => await context.entrypoint(protocols.card.specifications).call({
|
|
104
|
+
params: { id: parent }, query: encodeSpecificationQuery(query), timeout,
|
|
105
|
+
}),
|
|
106
|
+
|
|
107
|
+
get: async id => await context.entrypoint(protocols.spec.get).call({ params: { id }, timeout }),
|
|
108
|
+
|
|
109
|
+
revisions: async (id, limit) => {
|
|
110
|
+
const answer = await context.entrypoint(protocols.spec.revisions).call({
|
|
111
|
+
params: { id }, query: limit != null ? { limit } : {}, timeout,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return answer.items
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
relationships: {
|
|
119
|
+
list: async query => await context.entrypoint(protocols.link.list).call({
|
|
120
|
+
query: encodeRelationshipQuery(query), timeout,
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
transitions: {
|
|
125
|
+
get: async id => await context.entrypoint(protocols.transition.get).call({
|
|
126
|
+
params: { transition: id }, timeout,
|
|
127
|
+
}),
|
|
128
|
+
|
|
129
|
+
list: async query => {
|
|
130
|
+
// The tree reads the log through ONE card; a project-wide read has no route to travel.
|
|
131
|
+
if (query.card == null) {
|
|
132
|
+
throw new PlanningUnsupported('client:transitions-without-card')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return await context.entrypoint(protocols.card.transitions).call({
|
|
136
|
+
params: { id: query.card }, query: encodeTransitionQuery(query), timeout,
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
commits,
|
|
142
|
+
|
|
143
|
+
execute: async (exec, executeOpts) => {
|
|
144
|
+
// Neither the actor nor a hold travels: the server decides who wrote, and the wait is ours.
|
|
145
|
+
const { actor: _actor, ...body } = exec
|
|
146
|
+
const view = await context.entrypoint(protocols.execute).call({ body, timeout })
|
|
147
|
+
const stores = opts.stores?.()
|
|
148
|
+
if (stores != null) {
|
|
149
|
+
await applyReceipt(stores, view)
|
|
150
|
+
}
|
|
151
|
+
const receipt = receiptOf(view)
|
|
152
|
+
if (executeOpts?.wait === true) {
|
|
153
|
+
receipt.card = await receipt.committed({ timeout: executeOpts.timeout })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return receipt
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
model: async <R extends Workcard = Workcard>(card: R | string): Promise<WorkcardModel<R>> => {
|
|
160
|
+
await opts.loadSchemas?.()
|
|
161
|
+
const record = typeof card === 'string' ? await facade.cards.get(card) as R : card
|
|
162
|
+
|
|
163
|
+
return modelOf<R>(record, facade)
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return facade
|
|
168
|
+
}
|
|
169
|
+
|
package/src/feed.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
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'
|
|
4
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
5
|
+
import { applyCards, applyCommitEvent } from './events.js'
|
|
6
|
+
import { planningStoresOf, syncCards, syncLinks } from './stores.js'
|
|
7
|
+
import type { PlanningClientService, PlanningFeed, PlanningFeedOptions, PlanningFeedState } from './types.js'
|
|
8
|
+
import { clean } from './utils/record.js'
|
|
9
|
+
|
|
10
|
+
const linkCriteriaOf = (query: RelationshipQuery): Criteria<Relationship> =>
|
|
11
|
+
clean({ from: query.from, to: query.to, type: query.type }) as Criteria<Relationship>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Keep the state mirror current: subscribe to commits FIRST, then seed from the list, then fold
|
|
15
|
+
* every frame — and, with `refresh`, re-seed on an interval as the authoritative backstop to a
|
|
16
|
+
* socket that can drop frames. React-free: a hook wraps it, a Node process uses it as it is.
|
|
17
|
+
*
|
|
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.
|
|
22
|
+
*
|
|
23
|
+
* Without a socket opener the feed is a seed plus its refresh; `commits.wait` still long-polls.
|
|
24
|
+
*
|
|
25
|
+
* @throws {SyntaxError} when the context has no planning client or no planning stores
|
|
26
|
+
*/
|
|
27
|
+
export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C>>(
|
|
28
|
+
context: T, opts: PlanningFeedOptions = {}
|
|
29
|
+
): PlanningFeed => {
|
|
30
|
+
const stores = planningStoresOf(context)
|
|
31
|
+
if (stores == null) {
|
|
32
|
+
throw new SyntaxError('Planning feed needs appendPlanningStores(context)')
|
|
33
|
+
}
|
|
34
|
+
const service = context.service<PlanningClientService>(PLANNING_SERVICE)
|
|
35
|
+
const facade = service.for(opts.scope)
|
|
36
|
+
|
|
37
|
+
const state: PlanningFeedState = { connected: false, seeded: false, error: null }
|
|
38
|
+
const report = (patch: Partial<PlanningFeedState>): void => {
|
|
39
|
+
const next = { ...state, ...patch }
|
|
40
|
+
if (next.connected === state.connected && next.seeded === state.seeded && next.error === state.error) {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
Object.assign(state, next)
|
|
44
|
+
opts.onChange?.({ ...state })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const paged = opts.query?.size != null && opts.query.size > 0
|
|
48
|
+
const where = opts.where ?? criteriaOf(opts.query)
|
|
49
|
+
const touched = new Set<string>()
|
|
50
|
+
let seeding = 0
|
|
51
|
+
let stopped = false
|
|
52
|
+
let unsubscribe: Unsubscribe | null = null
|
|
53
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
54
|
+
|
|
55
|
+
const seed = async (): Promise<void> => {
|
|
56
|
+
if (stopped) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
seeding += 1
|
|
60
|
+
try {
|
|
61
|
+
const answer = await facade.cards.list(paged ? opts.query : { ...opts.query, size: 0 })
|
|
62
|
+
if (stopped) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (paged) {
|
|
66
|
+
await applyCards(stores, answer.items)
|
|
67
|
+
} else {
|
|
68
|
+
await syncCards(stores.cards, answer.items, where, { keep: touched })
|
|
69
|
+
}
|
|
70
|
+
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))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
report({ seeded: true, error: null })
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (!stopped) {
|
|
79
|
+
report({ error: e as Error })
|
|
80
|
+
}
|
|
81
|
+
} finally {
|
|
82
|
+
seeding -= 1
|
|
83
|
+
if (seeding === 0) {
|
|
84
|
+
touched.clear()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ready = (async () => {
|
|
90
|
+
try {
|
|
91
|
+
const stop = await service.commits.subscribe(async event => {
|
|
92
|
+
if (seeding > 0) {
|
|
93
|
+
touched.add(event.card)
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
await applyCommitEvent(stores, event, facade)
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error('Planning feed apply error:', e)
|
|
99
|
+
}
|
|
100
|
+
}, opts.filter)
|
|
101
|
+
if (stopped) {
|
|
102
|
+
stop()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
unsubscribe = stop
|
|
106
|
+
report({ connected: service.commits.connected() })
|
|
107
|
+
} catch (e) {
|
|
108
|
+
report({ error: e as Error })
|
|
109
|
+
}
|
|
110
|
+
await seed()
|
|
111
|
+
if (!stopped && opts.refresh != null && opts.refresh > 0) {
|
|
112
|
+
timer = setInterval(() => { void seed() }, opts.refresh)
|
|
113
|
+
}
|
|
114
|
+
})()
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
get connected() { return state.connected },
|
|
118
|
+
get seeded() { return state.seeded },
|
|
119
|
+
get error() { return state.error },
|
|
120
|
+
ready,
|
|
121
|
+
refresh: seed,
|
|
122
|
+
stop: async () => {
|
|
123
|
+
stopped = true
|
|
124
|
+
if (timer != null) {
|
|
125
|
+
clearInterval(timer)
|
|
126
|
+
}
|
|
127
|
+
unsubscribe?.()
|
|
128
|
+
unsubscribe = null
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/helper.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import { PLANNING_SERVICE } from '@owlmeans/planning'
|
|
3
|
+
import type { PlanningFacade, PlanningScope, Workcard, WorkcardModel } from '@owlmeans/planning'
|
|
4
|
+
import type { PlanningClientService } from './types.js'
|
|
5
|
+
|
|
6
|
+
/** The facade of the context's planning client — `context.planning().for(scope)` without the mixin type. */
|
|
7
|
+
export const planningOf = <C extends BasicConfig, T extends BasicContext<C>>(
|
|
8
|
+
context: T, scope?: Partial<PlanningScope>
|
|
9
|
+
): PlanningFacade => context.service<PlanningClientService>(PLANNING_SERVICE).for(scope)
|
|
10
|
+
|
|
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
|
+
*/
|
|
16
|
+
export const planningModelOf = async <R extends Workcard = Workcard, C extends BasicConfig = BasicConfig, T extends BasicContext<C> = BasicContext<C>>(
|
|
17
|
+
context: T, card: R | string, scope?: Partial<PlanningScope>
|
|
18
|
+
): Promise<WorkcardModel<R>> => await planningOf<C, T>(context, scope).model<R>(card)
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type * from './types.js'
|
|
2
|
+
|
|
3
|
+
export * from './consts.js'
|
|
4
|
+
export * from './client.js'
|
|
5
|
+
export * from './facade.js'
|
|
6
|
+
export * from './commits.js'
|
|
7
|
+
export * from './stores.js'
|
|
8
|
+
export * from './events.js'
|
|
9
|
+
export * from './feed.js'
|
|
10
|
+
export * from './helper.js'
|
package/src/stores.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import type { Relationship, Workcard } from '@owlmeans/planning'
|
|
3
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
4
|
+
import { appendStateResource } from '@owlmeans/state'
|
|
5
|
+
import type { StateResourceAppend } from '@owlmeans/state'
|
|
6
|
+
import { DEFAULT_STORE_ALIASES } from './consts.js'
|
|
7
|
+
import type { PlanningStoreAliases, PlanningStores, SyncOptions, WithPlanningStores } from './types.js'
|
|
8
|
+
import { idsOf, putCard, putLink } from './utils/record.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register the planning state mirror: ONE card store for every kind, a link store and a commit
|
|
12
|
+
* store, and `context.planningStores()`.
|
|
13
|
+
*
|
|
14
|
+
* Projects, cards and specifications share one id space, so they share one store — a store per
|
|
15
|
+
* kind would let a card list that reloads drop the projects beside it, and would give a record two
|
|
16
|
+
* homes the moment its kind is read wrong. Idempotent, like every `append*`.
|
|
17
|
+
*/
|
|
18
|
+
export const appendPlanningStores = <C extends BasicConfig, T extends BasicContext<C>>(
|
|
19
|
+
context: T, aliases?: Partial<PlanningStoreAliases>
|
|
20
|
+
): T & WithPlanningStores & StateResourceAppend => {
|
|
21
|
+
const names: PlanningStoreAliases = { ...DEFAULT_STORE_ALIASES, ...aliases }
|
|
22
|
+
let ctx = appendStateResource<C, T, Workcard>(context, names.cards)
|
|
23
|
+
ctx = appendStateResource<C, typeof ctx, Relationship>(ctx, names.links)
|
|
24
|
+
ctx = appendStateResource<C, typeof ctx>(ctx, names.commits)
|
|
25
|
+
|
|
26
|
+
const result = ctx as T & WithPlanningStores & StateResourceAppend
|
|
27
|
+
if (result.planningStores == null) {
|
|
28
|
+
result.planningStores = () => ({
|
|
29
|
+
cards: result.getStateResource(names.cards),
|
|
30
|
+
links: result.getStateResource(names.links),
|
|
31
|
+
commits: result.getStateResource(names.commits),
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
+
|
|
42
|
+
return typeof ctx.planningStores === 'function' ? ctx.planningStores() : null
|
|
43
|
+
}
|
|
44
|
+
|
|
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
|
+
*/
|
|
53
|
+
export const syncCards = async (
|
|
54
|
+
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
|
+
}
|
|
61
|
+
|
|
62
|
+
/** {@link syncCards} for the link store. */
|
|
63
|
+
export const syncLinks = async (
|
|
64
|
+
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
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { ClientConfig, ClientContext } from '@owlmeans/client-context'
|
|
2
|
+
import type { LazyService } from '@owlmeans/context'
|
|
3
|
+
import type { AbstractRequest, EntrypointProtocolDeclaration } from '@owlmeans/entrypoint'
|
|
4
|
+
import type {
|
|
5
|
+
CommitFilter, CommitSource, CommitState, PlanningFacade, PlanningProtocols, PlanningSchemaRegistry,
|
|
6
|
+
PlanningScope, PlanningService, Relationship, RelationshipQuery, TransitionAction, Workcard,
|
|
7
|
+
WorkcardKind, WorkcardQuery,
|
|
8
|
+
} from '@owlmeans/planning'
|
|
9
|
+
import type { Criteria, ResourceRecord } from '@owlmeans/resource'
|
|
10
|
+
import type { Connection } from '@owlmeans/socket'
|
|
11
|
+
import type { StateAlias, StateResource } from '@owlmeans/state'
|
|
12
|
+
|
|
13
|
+
export interface Config extends ClientConfig { }
|
|
14
|
+
|
|
15
|
+
export interface Context<C extends Config = Config> extends ClientContext<C> { }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Opens the commit socket. Injected, because the way a socket is opened is the host's: a browser
|
|
19
|
+
* wraps `@owlmeans/client-socket` (which puts the session token on the connection), a Node client
|
|
20
|
+
* usually has no socket at all. Answering `null` means "no socket" — the client then long-polls.
|
|
21
|
+
*/
|
|
22
|
+
export interface PlanningSocketOpener {
|
|
23
|
+
(protocol: EntrypointProtocolDeclaration, request?: Partial<AbstractRequest>): Promise<Connection | null>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PlanningClientOptions {
|
|
27
|
+
/** The tree the server mounted — the same `makePlanningProtocols` call, alias for alias. */
|
|
28
|
+
protocols: PlanningProtocols
|
|
29
|
+
/**
|
|
30
|
+
* The default facade's scope. Advisory on a client: the server derives the entity and the actor
|
|
31
|
+
* from the credential, never from anything this sends.
|
|
32
|
+
*/
|
|
33
|
+
scope?: Partial<PlanningScope>
|
|
34
|
+
/** How the commit socket is opened. Without it `commits.wait` long-polls only. */
|
|
35
|
+
socket?: PlanningSocketOpener
|
|
36
|
+
/**
|
|
37
|
+
* Load the schema bundle in the background once the context is ready (default `true`). The load
|
|
38
|
+
* never fails or blocks the context; whatever needs the schemas (`model()`, `loadSchemas()`)
|
|
39
|
+
* loads them on first use either way.
|
|
40
|
+
*/
|
|
41
|
+
schemas?: boolean
|
|
42
|
+
/** The longest single long poll, in seconds (default `DEFAULT_COMMIT_POLL`). */
|
|
43
|
+
poll?: number
|
|
44
|
+
/** Bind the protocol tree as client entrypoints (default `true`). */
|
|
45
|
+
bind?: boolean
|
|
46
|
+
/** The HTTP deadline of every call that is not a long poll, in milliseconds. */
|
|
47
|
+
timeout?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What `appendPlanningClient` registers under `PLANNING_SERVICE`. */
|
|
51
|
+
export interface PlanningClientService extends PlanningService, LazyService {
|
|
52
|
+
/** A facade over the remote tree. The scope is advisory (see {@link PlanningClientOptions.scope}). */
|
|
53
|
+
for: (scope?: Partial<PlanningScope>) => PlanningFacade
|
|
54
|
+
commits: RemoteCommitSource
|
|
55
|
+
/** The registry, loaded from the server's bundle — once, unless `force`d. */
|
|
56
|
+
loadSchemas: (opts?: { force?: boolean }) => Promise<PlanningSchemaRegistry>
|
|
57
|
+
/** Release the shared commit socket. */
|
|
58
|
+
close: () => Promise<void>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface WithPlanningClient {
|
|
62
|
+
planning: () => PlanningClientService
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RemoteCommitSource extends CommitSource {
|
|
66
|
+
/** A socket is open under this source. */
|
|
67
|
+
connected: () => boolean
|
|
68
|
+
/** Release the shared socket. Subscriptions stop receiving until the next `subscribe`. */
|
|
69
|
+
close: () => Promise<void>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface RemoteCommitSourceOptions {
|
|
73
|
+
socket?: PlanningSocketOpener
|
|
74
|
+
/** Seconds. */
|
|
75
|
+
poll?: number
|
|
76
|
+
/** Milliseconds, for the non-holding status read. */
|
|
77
|
+
timeout?: number
|
|
78
|
+
/** The state mirror a settled commit is folded into, when one is registered. */
|
|
79
|
+
stores?: () => PlanningStores | null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RemoteFacadeOptions {
|
|
83
|
+
commits: RemoteCommitSource
|
|
84
|
+
schemas: PlanningSchemaRegistry
|
|
85
|
+
/** Resolves the registry before a model is built. */
|
|
86
|
+
loadSchemas?: () => Promise<PlanningSchemaRegistry>
|
|
87
|
+
timeout?: number
|
|
88
|
+
stores?: () => PlanningStores | null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One row per transition the client wrote or saw settle. */
|
|
92
|
+
export interface PlanningCommitRecord extends ResourceRecord {
|
|
93
|
+
/** The transition id. */
|
|
94
|
+
id: string
|
|
95
|
+
card?: string
|
|
96
|
+
entityId?: string
|
|
97
|
+
project?: string
|
|
98
|
+
kind?: WorkcardKind
|
|
99
|
+
type?: string
|
|
100
|
+
seq?: number
|
|
101
|
+
action?: TransitionAction
|
|
102
|
+
state: CommitState
|
|
103
|
+
at?: string
|
|
104
|
+
error?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface PlanningStoreAliases {
|
|
108
|
+
cards: StateAlias<Workcard>
|
|
109
|
+
links: StateAlias<Relationship>
|
|
110
|
+
commits: StateAlias<PlanningCommitRecord>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PlanningStores {
|
|
114
|
+
cards: StateResource<Workcard>
|
|
115
|
+
links: StateResource<Relationship>
|
|
116
|
+
commits: StateResource<PlanningCommitRecord>
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface WithPlanningStores {
|
|
120
|
+
planningStores: () => PlanningStores
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SyncOptions {
|
|
124
|
+
/** Ids never dropped by this sync — records written by a commit while the list was in flight. */
|
|
125
|
+
keep?: Iterable<string>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PlanningFeedOptions {
|
|
129
|
+
/** What the seeding list asks for. Unpaged unless the query pages. */
|
|
130
|
+
query?: WorkcardQuery
|
|
131
|
+
/**
|
|
132
|
+
* The part of the store the seed is authoritative for: rows matching it that the answer does not
|
|
133
|
+
* name are dropped. `criteriaOf(query)` when omitted; a PAGED seed never drops anything.
|
|
134
|
+
*/
|
|
135
|
+
where?: Criteria<Workcard>
|
|
136
|
+
/** Also seed the link store from this query. */
|
|
137
|
+
links?: RelationshipQuery
|
|
138
|
+
/** Which commit frames fold into the store. Every frame the server sends when omitted. */
|
|
139
|
+
filter?: CommitFilter
|
|
140
|
+
/** Re-seed on this interval, in milliseconds — the authoritative poll beside the socket. */
|
|
141
|
+
refresh?: number
|
|
142
|
+
scope?: Partial<PlanningScope>
|
|
143
|
+
/** Called on every change of {@link PlanningFeedState}. */
|
|
144
|
+
onChange?: (state: PlanningFeedState) => void
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface PlanningFeedState {
|
|
148
|
+
/** The commit socket is open. */
|
|
149
|
+
connected: boolean
|
|
150
|
+
/** The authoritative list has been written into the store at least once. */
|
|
151
|
+
seeded: boolean
|
|
152
|
+
/** What the last seeding call threw, if it threw. */
|
|
153
|
+
error: Error | null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface PlanningFeed extends Readonly<PlanningFeedState> {
|
|
157
|
+
/** Settles after the first seed attempt (it never rejects — read `error`). */
|
|
158
|
+
ready: Promise<void>
|
|
159
|
+
/** Seed again now. */
|
|
160
|
+
refresh: () => Promise<void>
|
|
161
|
+
/** Stop folding frames and refreshing. The shared socket stays open. */
|
|
162
|
+
stop: () => Promise<void>
|
|
163
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { PlanningCommitRecord, PlanningStores } from '../types.js'
|
|
2
|
+
import type { CommitState, Relationship, Workcard } from '@owlmeans/planning'
|
|
3
|
+
import type { ResourceRecord } from '@owlmeans/resource'
|
|
4
|
+
import type { StateResource } from '@owlmeans/state'
|
|
5
|
+
|
|
6
|
+
const stable = (value: unknown): unknown => {
|
|
7
|
+
if (Array.isArray(value)) {
|
|
8
|
+
return value.map(stable)
|
|
9
|
+
}
|
|
10
|
+
if (value != null && typeof value === 'object') {
|
|
11
|
+
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
|
12
|
+
.filter(([, entry]) => entry !== undefined)
|
|
13
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
14
|
+
.map(([key, entry]) => [key, stable(entry)]))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Same content, whatever order the keys arrived in. */
|
|
21
|
+
export const sameRecord = (left: unknown, right: unknown): boolean =>
|
|
22
|
+
JSON.stringify(stable(left)) === JSON.stringify(stable(right))
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Write one card unless the store already holds a NEWER fold of it.
|
|
26
|
+
*
|
|
27
|
+
* `seq` is the last folded transition, so a lower one is an older picture — a list fetched before a
|
|
28
|
+
* commit landed must not undo that commit. The head only ever grows: a head this client allocated
|
|
29
|
+
* (`applyReceipt`) stays until a fold catches up with it.
|
|
30
|
+
*/
|
|
31
|
+
export const putCard = async (store: StateResource<Workcard>, card: Workcard): Promise<Workcard> => {
|
|
32
|
+
if (card.id == null) {
|
|
33
|
+
return card
|
|
34
|
+
}
|
|
35
|
+
const known = await store.load(card.id)
|
|
36
|
+
if (known != null && (known.seq ?? 0) > (card.seq ?? 0)) {
|
|
37
|
+
return known
|
|
38
|
+
}
|
|
39
|
+
const head = Math.max(card.head ?? card.seq ?? 0, known?.head ?? 0)
|
|
40
|
+
const next = head > (card.head ?? card.seq ?? 0) ? { ...card, head } : card
|
|
41
|
+
if (known != null && sameRecord(known, next)) {
|
|
42
|
+
return known
|
|
43
|
+
}
|
|
44
|
+
await store.save(next)
|
|
45
|
+
|
|
46
|
+
return next
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const putLink = async (store: StateResource<Relationship>, link: Relationship): Promise<void> => {
|
|
50
|
+
if (link.id == null) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const known = await store.load(link.id)
|
|
54
|
+
if (known != null && sameRecord(known, link)) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
await store.save(link)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Drop a card, whatever lives under it when it is a project, and every link touching any of them. */
|
|
61
|
+
export const dropCard = async (stores: PlanningStores, id: string, project: boolean): Promise<void> => {
|
|
62
|
+
const ids = [id]
|
|
63
|
+
if (project) {
|
|
64
|
+
const children = await stores.cards.list({ parents: { $contains: [id] } })
|
|
65
|
+
ids.push(...children.items.map(child => child.id).filter((child): child is string => child != null))
|
|
66
|
+
}
|
|
67
|
+
await stores.cards.purge({ id: { $in: ids } })
|
|
68
|
+
if (await stores.links.count({ $or: [{ from: { $in: ids } }, { to: { $in: ids } }] }) > 0) {
|
|
69
|
+
await stores.links.purge({ $or: [{ from: { $in: ids } }, { to: { $in: ids } }] })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Record what is known about a transition, merged over what was known before. */
|
|
74
|
+
export const putCommit = async (
|
|
75
|
+
store: StateResource<PlanningCommitRecord>, record: Partial<PlanningCommitRecord> & { id: string, state: CommitState }
|
|
76
|
+
): Promise<PlanningCommitRecord> => {
|
|
77
|
+
const known = await store.load(record.id)
|
|
78
|
+
const next = { ...known, ...clean(record) } as PlanningCommitRecord
|
|
79
|
+
if (known != null && sameRecord(known, next)) {
|
|
80
|
+
return known
|
|
81
|
+
}
|
|
82
|
+
await store.save(next)
|
|
83
|
+
|
|
84
|
+
return next
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const clean = <T extends object>(record: T): T =>
|
|
88
|
+
Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
|
|
89
|
+
|
|
90
|
+
export const idsOf = <T extends ResourceRecord>(records: T[]): string[] =>
|
|
91
|
+
records.map(record => record.id).filter((id): id is string => id != null)
|