@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.
Files changed (67) hide show
  1. package/README.md +128 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/client-planning/SKILL.md +212 -0
  4. package/build/client.d.ts +26 -0
  5. package/build/client.d.ts.map +1 -0
  6. package/build/client.js +120 -0
  7. package/build/client.js.map +1 -0
  8. package/build/commits.d.ts +17 -0
  9. package/build/commits.d.ts.map +1 -0
  10. package/build/commits.js +205 -0
  11. package/build/commits.js.map +1 -0
  12. package/build/consts.d.ts +27 -0
  13. package/build/consts.d.ts.map +1 -0
  14. package/build/consts.js +29 -0
  15. package/build/consts.js.map +1 -0
  16. package/build/events.d.ts +27 -0
  17. package/build/events.d.ts.map +1 -0
  18. package/build/events.js +109 -0
  19. package/build/events.js.map +1 -0
  20. package/build/facade.d.ts +19 -0
  21. package/build/facade.d.ts.map +1 -0
  22. package/build/facade.js +130 -0
  23. package/build/facade.js.map +1 -0
  24. package/build/feed.d.ts +18 -0
  25. package/build/feed.d.ts.map +1 -0
  26. package/build/feed.js +123 -0
  27. package/build/feed.js.map +1 -0
  28. package/build/helper.d.ts +11 -0
  29. package/build/helper.d.ts.map +1 -0
  30. package/build/helper.js +10 -0
  31. package/build/helper.js.map +1 -0
  32. package/build/index.d.ts +10 -0
  33. package/build/index.d.ts.map +1 -0
  34. package/build/index.js +9 -0
  35. package/build/index.js.map +1 -0
  36. package/build/stores.d.ts +28 -0
  37. package/build/stores.d.ts.map +1 -0
  38. package/build/stores.js +61 -0
  39. package/build/stores.js.map +1 -0
  40. package/build/types.d.ts +147 -0
  41. package/build/types.d.ts.map +1 -0
  42. package/build/types.js +2 -0
  43. package/build/types.js.map +1 -0
  44. package/build/utils/record.d.ts +25 -0
  45. package/build/utils/record.d.ts.map +1 -0
  46. package/build/utils/record.js +72 -0
  47. package/build/utils/record.js.map +1 -0
  48. package/package.json +55 -0
  49. package/src/client.ts +153 -0
  50. package/src/commits.ts +247 -0
  51. package/src/consts.ts +38 -0
  52. package/src/events.ts +121 -0
  53. package/src/facade.ts +169 -0
  54. package/src/feed.ts +131 -0
  55. package/src/helper.ts +18 -0
  56. package/src/index.ts +10 -0
  57. package/src/stores.ts +81 -0
  58. package/src/types.ts +163 -0
  59. package/src/utils/record.ts +91 -0
  60. package/tests/commits.spec.ts +67 -0
  61. package/tests/context.ts +301 -0
  62. package/tests/events.spec.ts +95 -0
  63. package/tests/facade.spec.ts +83 -0
  64. package/tests/feed.spec.ts +39 -0
  65. package/tests/stores.spec.ts +67 -0
  66. package/tests/tsconfig.json +17 -0
  67. 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)