@owlmeans/client-planning 0.1.18-rc.2 → 0.1.18-rc.21
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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/client-planning/SKILL.md +39 -16
- package/build/client.d.ts.map +1 -1
- package/build/client.js +7 -0
- package/build/client.js.map +1 -1
- package/build/commits.d.ts +1 -1
- package/build/commits.d.ts.map +1 -1
- package/build/commits.js +9 -7
- package/build/commits.js.map +1 -1
- package/build/consts.d.ts +0 -12
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +0 -14
- 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 +64 -0
- package/build/definitions.js.map +1 -0
- package/build/events.d.ts +1 -1
- package/build/events.d.ts.map +1 -1
- package/build/events.js +9 -9
- package/build/events.js.map +1 -1
- package/build/facade.d.ts.map +1 -1
- package/build/facade.js +13 -2
- package/build/facade.js.map +1 -1
- package/build/feed.d.ts.map +1 -1
- package/build/feed.js +5 -3
- package/build/feed.js.map +1 -1
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/stores.d.ts +1 -1
- package/build/stores.d.ts.map +1 -1
- package/build/stores.js +7 -7
- package/build/stores.js.map +1 -1
- package/build/types.d.ts +16 -1
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +6 -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.d.ts +26 -7
- package/build/utils/record.d.ts.map +1 -1
- package/build/utils/record.js +9 -7
- package/build/utils/record.js.map +1 -1
- package/build/utils/types.d.ts +3 -0
- package/build/utils/types.d.ts.map +1 -0
- package/build/utils/types.js +2 -0
- package/build/utils/types.js.map +1 -0
- package/build/utils.d.ts +13 -0
- package/build/utils.d.ts.map +1 -0
- package/build/utils.js +15 -0
- package/build/utils.js.map +1 -0
- package/package.json +17 -16
- package/src/client.ts +9 -0
- package/src/commits.ts +10 -17
- package/src/consts.ts +0 -21
- package/src/definitions.ts +88 -0
- package/src/events.ts +10 -11
- package/src/facade.ts +14 -2
- package/src/feed.ts +7 -5
- package/src/index.ts +2 -0
- package/src/stores.ts +8 -9
- package/src/types.local.ts +6 -0
- package/src/types.ts +20 -3
- package/src/utils/record.ts +11 -7
- package/src/utils/types.ts +3 -0
- package/src/utils.ts +20 -0
- package/tests/context.ts +14 -3
- package/tests/definitions.spec.ts +56 -0
package/src/consts.ts
CHANGED
|
@@ -1,24 +1,3 @@
|
|
|
1
|
-
import type { Relationship, Workcard } from '@owlmeans/planning'
|
|
2
|
-
import { stateAlias } from '@owlmeans/state'
|
|
3
|
-
import type { StateAlias } from '@owlmeans/state'
|
|
4
|
-
import type { PlanningCommitRecord, PlanningStoreAliases } from './types.js'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The ONE card store. Projects, cards and specifications share an id space on the server, so they
|
|
8
|
-
* share it here too — a store per kind would let a list reload of one kind drop another's rows.
|
|
9
|
-
*/
|
|
10
|
-
export const CARDS: StateAlias<Workcard> = stateAlias<Workcard>('planning-card-state')
|
|
11
|
-
|
|
12
|
-
export const LINKS: StateAlias<Relationship> = stateAlias<Relationship>('planning-link-state')
|
|
13
|
-
|
|
14
|
-
/** What the client has learned about each transition it wrote or saw, keyed by transition id. */
|
|
15
|
-
export const COMMITS: StateAlias<PlanningCommitRecord> = stateAlias<PlanningCommitRecord>('planning-commit-state')
|
|
16
|
-
|
|
17
|
-
export const DEFAULT_STORE_ALIASES: Readonly<PlanningStoreAliases> = Object.freeze({
|
|
18
|
-
cards: CARDS,
|
|
19
|
-
links: LINKS,
|
|
20
|
-
commits: COMMITS,
|
|
21
|
-
})
|
|
22
1
|
|
|
23
2
|
/**
|
|
24
3
|
* How much longer than its own `wait` a long poll's HTTP deadline is, in milliseconds — a held
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
+
import { PlanningUnsupported, SchemaWriteMode, scopedRegistryOf } from '@owlmeans/planning'
|
|
3
|
+
import type {
|
|
4
|
+
PlanningProtocols, SchemaDefineReply, SchemaDefineRequest, ScopedSchemaRegistry,
|
|
5
|
+
} from '@owlmeans/planning'
|
|
6
|
+
import type { RemoteDefinitions, RemoteDefinitionsOptions } from './types.js'
|
|
7
|
+
|
|
8
|
+
const clean = <T extends object>(record: T): T =>
|
|
9
|
+
Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Data-defined types and flows over a tree declared with `definitions: true`: a layer's registry is
|
|
13
|
+
* read through `schema.list` (with its `project`) and cached per project; every write goes through
|
|
14
|
+
* `schema.define` and drops the whole cache — an organization-wide write reaches every project's
|
|
15
|
+
* layer. The records of a layer are the server's alone (`records` refuses).
|
|
16
|
+
*
|
|
17
|
+
* @throws {PlanningUnsupported} for a tree declared without `definitions`
|
|
18
|
+
*/
|
|
19
|
+
export const makeRemoteDefinitions = <C extends BasicConfig, T extends BasicContext<C>>(
|
|
20
|
+
context: T, protocols: PlanningProtocols, opts: RemoteDefinitionsOptions = {}
|
|
21
|
+
): RemoteDefinitions => {
|
|
22
|
+
const define = protocols.schema.define
|
|
23
|
+
if (define == null) {
|
|
24
|
+
throw new PlanningUnsupported('client:definitions')
|
|
25
|
+
}
|
|
26
|
+
const cache = new Map<string, Promise<ScopedSchemaRegistry>>()
|
|
27
|
+
|
|
28
|
+
const invalidate = (): void => {
|
|
29
|
+
cache.clear()
|
|
30
|
+
opts.onWrite?.()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const registry = (project?: string): Promise<ScopedSchemaRegistry> => {
|
|
34
|
+
const key = project ?? ''
|
|
35
|
+
let entry = cache.get(key)
|
|
36
|
+
if (entry == null) {
|
|
37
|
+
const loading = (async () => scopedRegistryOf(await context.entrypoint(protocols.schema.list).call({
|
|
38
|
+
query: project != null ? { project } : {}, timeout: opts.timeout,
|
|
39
|
+
})))()
|
|
40
|
+
entry = loading
|
|
41
|
+
cache.set(key, loading)
|
|
42
|
+
// A failed read is forgotten, so the next caller asks again instead of inheriting the error.
|
|
43
|
+
loading.catch(() => {
|
|
44
|
+
if (cache.get(key) === loading) {
|
|
45
|
+
cache.delete(key)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
return entry
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const write = async (request: SchemaDefineRequest): Promise<SchemaDefineReply> => {
|
|
53
|
+
try {
|
|
54
|
+
return await context.entrypoint(define).call({ body: clean(request), timeout: opts.timeout })
|
|
55
|
+
} finally {
|
|
56
|
+
invalidate()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
bundle: async project => (await registry(project)).bundle(),
|
|
62
|
+
|
|
63
|
+
registry,
|
|
64
|
+
|
|
65
|
+
records: async () => {
|
|
66
|
+
throw new PlanningUnsupported('client:definitions:records')
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
putType: async (type, layer) =>
|
|
70
|
+
(await write({ project: layer?.project, mode: SchemaWriteMode.Put, types: [type] })).records[0],
|
|
71
|
+
|
|
72
|
+
putFlow: async (flow, layer) =>
|
|
73
|
+
(await write({ project: layer?.project, mode: SchemaWriteMode.Put, flows: [flow] })).records[0],
|
|
74
|
+
|
|
75
|
+
define: async (declarations, layer) =>
|
|
76
|
+
(await write({ project: layer?.project, mode: SchemaWriteMode.Define, ...declarations })).records,
|
|
77
|
+
|
|
78
|
+
seed: async (declarations, layer) =>
|
|
79
|
+
(await write({ project: layer?.project, mode: SchemaWriteMode.Seed, ...declarations })).records,
|
|
80
|
+
|
|
81
|
+
retire: async (kind, key, layer) => {
|
|
82
|
+
const { records } = await write({ project: layer?.project, retire: [{ kind, key }] })
|
|
83
|
+
return records[records.length - 1]
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
invalidate,
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/events.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { CommitState, TransitionAction, WorkcardKind } from '@owlmeans/planning'
|
|
2
|
-
import type { CommitEvent, PlanningFacade, TransitionReceiptView, Workcard } from '@owlmeans/planning'
|
|
1
|
+
import { CommitState, TransitionAction, WorkcardKind, type CommitEvent, type PlanningFacade, type TransitionReceiptView, type Workcard } from '@owlmeans/planning'
|
|
3
2
|
import { syncLinks } from './stores.js'
|
|
4
3
|
import type { PlanningStores } from './types.js'
|
|
5
|
-
import {
|
|
4
|
+
import { recordHelper } from './utils/record.js'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Fold one commit frame into the mirror.
|
|
@@ -22,7 +21,7 @@ export const applyCommitEvent = async (
|
|
|
22
21
|
): Promise<void> => {
|
|
23
22
|
const known = await stores.cards.load(event.card)
|
|
24
23
|
|
|
25
|
-
await putCommit(stores.commits, {
|
|
24
|
+
await recordHelper.putCommit(stores.commits, {
|
|
26
25
|
id: event.transition,
|
|
27
26
|
card: event.card,
|
|
28
27
|
entityId: event.entityId,
|
|
@@ -50,7 +49,7 @@ export const applyCommitEvent = async (
|
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
if (event.action === TransitionAction.Delete) {
|
|
53
|
-
await dropCard(stores, event.card, event.kind === WorkcardKind.Project)
|
|
52
|
+
await recordHelper.dropCard(stores, event.card, event.kind === WorkcardKind.Project)
|
|
54
53
|
return
|
|
55
54
|
}
|
|
56
55
|
|
|
@@ -60,9 +59,9 @@ export const applyCommitEvent = async (
|
|
|
60
59
|
|
|
61
60
|
if (record === null) {
|
|
62
61
|
// Committed, and gone by the time it was read: a later delete won the race.
|
|
63
|
-
await dropCard(stores, event.card, event.kind === WorkcardKind.Project)
|
|
62
|
+
await recordHelper.dropCard(stores, event.card, event.kind === WorkcardKind.Project)
|
|
64
63
|
} else if (record !== undefined) {
|
|
65
|
-
await putCard(stores.cards, record)
|
|
64
|
+
await recordHelper.putCard(stores.cards, record)
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
if (facade != null && (event.action === TransitionAction.Link || event.action === TransitionAction.Unlink)) {
|
|
@@ -81,7 +80,7 @@ export const applyCommitEvent = async (
|
|
|
81
80
|
export const applyReceipt = async (stores: PlanningStores, receipt: TransitionReceiptView): Promise<void> => {
|
|
82
81
|
const { transition } = receipt
|
|
83
82
|
if (transition.id != null) {
|
|
84
|
-
await putCommit(stores.commits, {
|
|
83
|
+
await recordHelper.putCommit(stores.commits, {
|
|
85
84
|
id: transition.id,
|
|
86
85
|
card: transition.card,
|
|
87
86
|
entityId: transition.entityId,
|
|
@@ -99,11 +98,11 @@ export const applyReceipt = async (stores: PlanningStores, receipt: TransitionRe
|
|
|
99
98
|
return
|
|
100
99
|
}
|
|
101
100
|
if (transition.commit.state === CommitState.Committed && transition.action === TransitionAction.Delete) {
|
|
102
|
-
await dropCard(stores, transition.card, transition.kind === WorkcardKind.Project)
|
|
101
|
+
await recordHelper.dropCard(stores, transition.card, transition.kind === WorkcardKind.Project)
|
|
103
102
|
return
|
|
104
103
|
}
|
|
105
104
|
if (receipt.card != null) {
|
|
106
|
-
await putCard(stores.cards, receipt.card)
|
|
105
|
+
await recordHelper.putCard(stores.cards, receipt.card)
|
|
107
106
|
return
|
|
108
107
|
}
|
|
109
108
|
|
|
@@ -116,6 +115,6 @@ export const applyReceipt = async (stores: PlanningStores, receipt: TransitionRe
|
|
|
116
115
|
/** Write cards the caller read on its own, under the same newer-fold-wins rule as a commit. */
|
|
117
116
|
export const applyCards = async (stores: Pick<PlanningStores, 'cards'>, cards: Workcard[]): Promise<void> => {
|
|
118
117
|
for (const card of cards) {
|
|
119
|
-
await putCard(stores.cards, card)
|
|
118
|
+
await recordHelper.putCard(stores.cards, card)
|
|
120
119
|
}
|
|
121
120
|
}
|
package/src/facade.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
2
|
import {
|
|
3
|
-
CommitFailed, CommitState, DEFAULT_COMMIT_TIMEOUT, PlanningError, PlanningUnsupported, WorkcardNotFound,
|
|
3
|
+
CommitFailed, CommitState, DEFAULT_COMMIT_TIMEOUT, PlanningError, PlanningUnsupported, WorkcardKind, WorkcardNotFound,
|
|
4
4
|
encodeRelationshipQuery, encodeSpecificationQuery, encodeSummaryQuery, encodeTransitionQuery,
|
|
5
5
|
encodeWorkcardQuery, modelOf,
|
|
6
6
|
} from '@owlmeans/planning'
|
|
@@ -159,11 +159,23 @@ export const makeRemoteFacade = <C extends BasicConfig, T extends BasicContext<C
|
|
|
159
159
|
model: async <R extends Workcard = Workcard>(card: R | string): Promise<WorkcardModel<R>> => {
|
|
160
160
|
await opts.loadSchemas?.()
|
|
161
161
|
const record = typeof card === 'string' ? await facade.cards.get(card) as R : card
|
|
162
|
+
// A card of a project resolves its types in that project's layer; the organization's layer
|
|
163
|
+
// is the service's own bundle, and a specification's type is always code's.
|
|
164
|
+
const project = opts.definitions == null
|
|
165
|
+
? undefined
|
|
166
|
+
: record.kind === WorkcardKind.Project ? record.id : record.kind === WorkcardKind.Card ? record.parent : undefined
|
|
167
|
+
if (project == null) {
|
|
168
|
+
return modelOf<R>(record, facade)
|
|
169
|
+
}
|
|
162
170
|
|
|
163
|
-
return modelOf<R>(record, facade)
|
|
171
|
+
return modelOf<R>(record, { ...facade, schemas: await opts.definitions!.registry(project) })
|
|
164
172
|
},
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
if (opts.definitions != null) {
|
|
176
|
+
facade.definitions = opts.definitions
|
|
177
|
+
}
|
|
178
|
+
|
|
167
179
|
return facade
|
|
168
180
|
}
|
|
169
181
|
|
package/src/feed.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
-
import {
|
|
3
|
-
import type
|
|
2
|
+
import { logger } from '@owlmeans/log'
|
|
3
|
+
import { criteriaOf, PLANNING_SERVICE, type Relationship, type RelationshipQuery, type Unsubscribe } from '@owlmeans/planning'
|
|
4
4
|
import type { Criteria } from '@owlmeans/resource'
|
|
5
5
|
import { applyCards, applyCommitEvent } from './events.js'
|
|
6
6
|
import { planningStoresOf, syncCards, syncLinks } from './stores.js'
|
|
7
7
|
import type { PlanningClientService, PlanningFeed, PlanningFeedOptions, PlanningFeedState } from './types.js'
|
|
8
|
-
import {
|
|
8
|
+
import { recordHelper } from './utils/record.js'
|
|
9
|
+
|
|
10
|
+
const log = logger('client-planning')
|
|
9
11
|
|
|
10
12
|
const linkCriteriaOf = (query: RelationshipQuery): Criteria<Relationship> =>
|
|
11
|
-
clean({ from: query.from, to: query.to, type: query.type }) as Criteria<Relationship>
|
|
13
|
+
recordHelper.clean({ from: query.from, to: query.to, type: query.type }) as Criteria<Relationship>
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Keep the state mirror current: subscribe to commits FIRST, then seed from the list, then fold
|
|
@@ -95,7 +97,7 @@ export const makePlanningFeed = <C extends BasicConfig, T extends BasicContext<C
|
|
|
95
97
|
try {
|
|
96
98
|
await applyCommitEvent(stores, event, facade)
|
|
97
99
|
} catch (e) {
|
|
98
|
-
|
|
100
|
+
log.warn('Planning feed apply failed', { card: event.card, error: e })
|
|
99
101
|
}
|
|
100
102
|
}, opts.filter)
|
|
101
103
|
if (stopped) {
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export type * from './types.js'
|
|
2
2
|
|
|
3
3
|
export * from './consts.js'
|
|
4
|
+
export * from './utils.js'
|
|
4
5
|
export * from './client.js'
|
|
5
6
|
export * from './facade.js'
|
|
7
|
+
export * from './definitions.js'
|
|
6
8
|
export * from './commits.js'
|
|
7
9
|
export * from './stores.js'
|
|
8
10
|
export * from './events.js'
|
package/src/stores.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
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
|
|
6
|
-
import { DEFAULT_STORE_ALIASES } from './consts.js'
|
|
4
|
+
import { appendStateResource, type StateResourceAppend } from '@owlmeans/state'
|
|
5
|
+
import { DEFAULT_STORE_ALIASES } from './utils.js'
|
|
7
6
|
import type { PlanningStoreAliases, PlanningStores, SyncOptions, WithPlanningStores } from './types.js'
|
|
8
|
-
import {
|
|
7
|
+
import { recordHelper } from './utils/record.js'
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* Register the planning state mirror: ONE card store for every kind, a link store and a commit
|
|
@@ -53,9 +52,9 @@ export const planningStoresOf = (context: BasicContext<any>): PlanningStores | n
|
|
|
53
52
|
export const syncCards = async (
|
|
54
53
|
store: PlanningStores['cards'], items: Workcard[], where?: Criteria<Workcard>, opts?: SyncOptions
|
|
55
54
|
): Promise<void> => {
|
|
56
|
-
await dropUnnamed(store, idsOf(items), where, opts)
|
|
55
|
+
await dropUnnamed(store, recordHelper.idsOf(items), where, opts)
|
|
57
56
|
for (const item of items) {
|
|
58
|
-
await putCard(store, item)
|
|
57
|
+
await recordHelper.putCard(store, item)
|
|
59
58
|
}
|
|
60
59
|
}
|
|
61
60
|
|
|
@@ -63,9 +62,9 @@ export const syncCards = async (
|
|
|
63
62
|
export const syncLinks = async (
|
|
64
63
|
store: PlanningStores['links'], items: Relationship[], where?: Criteria<Relationship>, opts?: SyncOptions
|
|
65
64
|
): Promise<void> => {
|
|
66
|
-
await dropUnnamed(store, idsOf(items), where, opts)
|
|
65
|
+
await dropUnnamed(store, recordHelper.idsOf(items), where, opts)
|
|
67
66
|
for (const item of items) {
|
|
68
|
-
await putLink(store, item)
|
|
67
|
+
await recordHelper.putLink(store, item)
|
|
69
68
|
}
|
|
70
69
|
}
|
|
71
70
|
|
|
@@ -74,7 +73,7 @@ const dropUnnamed = async <T extends Workcard | Relationship>(
|
|
|
74
73
|
): Promise<void> => {
|
|
75
74
|
const keep = new Set([...named, ...(opts?.keep ?? [])])
|
|
76
75
|
const scoped = await (store as PlanningStores['cards']).list(where as Criteria<Workcard>)
|
|
77
|
-
const stale = idsOf(scoped.items).filter(id => !keep.has(id))
|
|
76
|
+
const stale = recordHelper.idsOf(scoped.items).filter(id => !keep.has(id))
|
|
78
77
|
if (stale.length > 0) {
|
|
79
78
|
await store.purge({ id: { $in: stale } })
|
|
80
79
|
}
|
package/src/types.ts
CHANGED
|
@@ -2,9 +2,9 @@ import type { ClientConfig, ClientContext } from '@owlmeans/client-context'
|
|
|
2
2
|
import type { LazyService } from '@owlmeans/context'
|
|
3
3
|
import type { AbstractRequest, EntrypointProtocolDeclaration } from '@owlmeans/entrypoint'
|
|
4
4
|
import type {
|
|
5
|
-
CommitFilter, CommitSource, CommitState, PlanningFacade, PlanningProtocols,
|
|
6
|
-
PlanningScope, PlanningService, Relationship, RelationshipQuery, TransitionAction,
|
|
7
|
-
WorkcardKind, WorkcardQuery,
|
|
5
|
+
CommitFilter, CommitSource, CommitState, PlanningDefinitions, PlanningFacade, PlanningProtocols,
|
|
6
|
+
PlanningSchemaRegistry, PlanningScope, PlanningService, Relationship, RelationshipQuery, TransitionAction,
|
|
7
|
+
Workcard, WorkcardKind, WorkcardQuery,
|
|
8
8
|
} from '@owlmeans/planning'
|
|
9
9
|
import type { Criteria, ResourceRecord } from '@owlmeans/resource'
|
|
10
10
|
import type { Connection } from '@owlmeans/socket'
|
|
@@ -56,6 +56,21 @@ export interface PlanningClientService extends PlanningService, LazyService {
|
|
|
56
56
|
loadSchemas: (opts?: { force?: boolean }) => Promise<PlanningSchemaRegistry>
|
|
57
57
|
/** Release the shared commit socket. */
|
|
58
58
|
close: () => Promise<void>
|
|
59
|
+
/** Data-defined types and flows — present when the tree was declared with `definitions: true`. */
|
|
60
|
+
definitions?: RemoteDefinitions
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The client half of `PlanningDefinitions`: layers cached per project, dropped on any write. */
|
|
64
|
+
export interface RemoteDefinitions extends PlanningDefinitions {
|
|
65
|
+
/** Forget every cached layer — the next read asks the server. */
|
|
66
|
+
invalidate: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RemoteDefinitionsOptions {
|
|
70
|
+
/** Milliseconds, for every definitions call. */
|
|
71
|
+
timeout?: number
|
|
72
|
+
/** Called after every write (the service reloads its own bundle on the next `model()`). */
|
|
73
|
+
onWrite?: () => void
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
export interface WithPlanningClient {
|
|
@@ -86,6 +101,8 @@ export interface RemoteFacadeOptions {
|
|
|
86
101
|
loadSchemas?: () => Promise<PlanningSchemaRegistry>
|
|
87
102
|
timeout?: number
|
|
88
103
|
stores?: () => PlanningStores | null
|
|
104
|
+
/** The data-defined schema surface, shared by every facade of the service. */
|
|
105
|
+
definitions?: RemoteDefinitions
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
/** One row per transition the client wrote or saw settle. */
|
package/src/utils/record.ts
CHANGED
|
@@ -18,7 +18,7 @@ const stable = (value: unknown): unknown => {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/** Same content, whatever order the keys arrived in. */
|
|
21
|
-
|
|
21
|
+
const sameRecord = (left: unknown, right: unknown): boolean =>
|
|
22
22
|
JSON.stringify(stable(left)) === JSON.stringify(stable(right))
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -28,7 +28,7 @@ export const sameRecord = (left: unknown, right: unknown): boolean =>
|
|
|
28
28
|
* commit landed must not undo that commit. The head only ever grows: a head this client allocated
|
|
29
29
|
* (`applyReceipt`) stays until a fold catches up with it.
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
const putCard = async (store: StateResource<Workcard>, card: Workcard): Promise<Workcard> => {
|
|
32
32
|
if (card.id == null) {
|
|
33
33
|
return card
|
|
34
34
|
}
|
|
@@ -46,7 +46,7 @@ export const putCard = async (store: StateResource<Workcard>, card: Workcard): P
|
|
|
46
46
|
return next
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
const putLink = async (store: StateResource<Relationship>, link: Relationship): Promise<void> => {
|
|
50
50
|
if (link.id == null) {
|
|
51
51
|
return
|
|
52
52
|
}
|
|
@@ -58,7 +58,7 @@ export const putLink = async (store: StateResource<Relationship>, link: Relation
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/** Drop a card, whatever lives under it when it is a project, and every link touching any of them. */
|
|
61
|
-
|
|
61
|
+
const dropCard = async (stores: PlanningStores, id: string, project: boolean): Promise<void> => {
|
|
62
62
|
const ids = [id]
|
|
63
63
|
if (project) {
|
|
64
64
|
const children = await stores.cards.list({ parents: { $contains: [id] } })
|
|
@@ -71,7 +71,7 @@ export const dropCard = async (stores: PlanningStores, id: string, project: bool
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/** Record what is known about a transition, merged over what was known before. */
|
|
74
|
-
|
|
74
|
+
const putCommit = async (
|
|
75
75
|
store: StateResource<PlanningCommitRecord>, record: Partial<PlanningCommitRecord> & { id: string, state: CommitState }
|
|
76
76
|
): Promise<PlanningCommitRecord> => {
|
|
77
77
|
const known = await store.load(record.id)
|
|
@@ -84,8 +84,12 @@ export const putCommit = async (
|
|
|
84
84
|
return next
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
const clean = <T extends object>(record: T): T =>
|
|
88
88
|
Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)) as T
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
const idsOf = <T extends ResourceRecord>(records: T[]): string[] =>
|
|
91
91
|
records.map(record => record.id).filter((id): id is string => id != null)
|
|
92
|
+
|
|
93
|
+
export const createRecordHelper = () => ({ sameRecord, putCard, putLink, dropCard, putCommit, clean, idsOf })
|
|
94
|
+
|
|
95
|
+
export const recordHelper = createRecordHelper()
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type StateAlias, stateAlias } from '@owlmeans/state'
|
|
2
|
+
import type { Relationship, Workcard } from '@owlmeans/planning'
|
|
3
|
+
import type { PlanningCommitRecord, PlanningStoreAliases } from './types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The ONE card store. Projects, cards and specifications share an id space on the server, so they
|
|
7
|
+
* share it here too — a store per kind would let a list reload of one kind drop another's rows.
|
|
8
|
+
*/
|
|
9
|
+
export const CARDS: StateAlias<Workcard> = stateAlias<Workcard>('planning-card-state')
|
|
10
|
+
|
|
11
|
+
export const LINKS: StateAlias<Relationship> = stateAlias<Relationship>('planning-link-state')
|
|
12
|
+
|
|
13
|
+
/** What the client has learned about each transition it wrote or saw, keyed by transition id. */
|
|
14
|
+
export const COMMITS: StateAlias<PlanningCommitRecord> = stateAlias<PlanningCommitRecord>('planning-commit-state')
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_STORE_ALIASES: Readonly<PlanningStoreAliases> = Object.freeze({
|
|
17
|
+
cards: CARDS,
|
|
18
|
+
links: LINKS,
|
|
19
|
+
commits: COMMITS,
|
|
20
|
+
})
|
package/tests/context.ts
CHANGED
|
@@ -86,6 +86,7 @@ const types: AnyTypeSchema[] = [
|
|
|
86
86
|
flows: [PROJECT_FLOW],
|
|
87
87
|
specifications: [{ category: 'brief', format: SpecificationFormat.Markdown, revisioned: true, type: SPEC }],
|
|
88
88
|
cardTypes: [STORY],
|
|
89
|
+
scopedCardTypes: true,
|
|
89
90
|
},
|
|
90
91
|
{
|
|
91
92
|
type: STORY,
|
|
@@ -118,6 +119,13 @@ export const protocols = makePlanningProtocols({
|
|
|
118
119
|
guards: [],
|
|
119
120
|
})
|
|
120
121
|
|
|
122
|
+
/** The same mount with the data-defined schema surface. */
|
|
123
|
+
export const definitionProtocols = makePlanningProtocols({
|
|
124
|
+
base: { alias: 'test:planning', path: '/planning' },
|
|
125
|
+
guards: [],
|
|
126
|
+
definitions: true,
|
|
127
|
+
})
|
|
128
|
+
|
|
121
129
|
const AUTH = { profileId: PROFILE_ID, userId: 'user-1', entityId: 'acme' } as unknown as Auth
|
|
122
130
|
const entityOf = (id: string): ResolvedEntity => ({ id, slug: id, iamKey: id })
|
|
123
131
|
|
|
@@ -136,6 +144,8 @@ export interface SuiteOptions extends Partial<Pick<PlanningClientOptions, 'poll'
|
|
|
136
144
|
stores?: boolean
|
|
137
145
|
/** The entity the transport authenticates as. */
|
|
138
146
|
entityId?: string
|
|
147
|
+
/** Mount the tree with `definitions: true` over a store holding data-defined schemas. */
|
|
148
|
+
definitions?: boolean
|
|
139
149
|
/** Runs after the server answered a call and before the client sees the answer. */
|
|
140
150
|
after?: (call: Call) => Promise<void>
|
|
141
151
|
}
|
|
@@ -193,9 +203,10 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
|
|
|
193
203
|
const server = makeBasicContext<BasicConfig>({
|
|
194
204
|
ready: false, service: 'client-planning-tests-server', type: AppType.Backend,
|
|
195
205
|
} as BasicConfig)
|
|
196
|
-
const
|
|
206
|
+
const tree = opts.definitions === true ? definitionProtocols : protocols
|
|
207
|
+
const store = makeMemoryPlanningStore({ sync: opts.sync !== false, schemas: opts.definitions === true })
|
|
197
208
|
appendPlanningService(server, { store, schemas: { types, flows } })
|
|
198
|
-
server.registerEntrypoints(servePlanningEntrypoints(
|
|
209
|
+
server.registerEntrypoints(servePlanningEntrypoints(tree))
|
|
199
210
|
await server.configure().init()
|
|
200
211
|
|
|
201
212
|
const calls: Call[] = []
|
|
@@ -245,7 +256,7 @@ export const makeSuite = async (opts: SuiteOptions = {}): Promise<Suite> => {
|
|
|
245
256
|
}))
|
|
246
257
|
|
|
247
258
|
appendPlanningClient(client, {
|
|
248
|
-
protocols,
|
|
259
|
+
protocols: tree,
|
|
249
260
|
poll: opts.poll ?? 2,
|
|
250
261
|
schemas: opts.schemas,
|
|
251
262
|
...(opts.socket === true
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
PlanningSchemaKind, PlanningUnsupported, SchemaOrigin, TransitionAction, WorkcardKind,
|
|
4
|
+
} from '@owlmeans/planning'
|
|
5
|
+
import type { WorkcardTypeSchema } from '@owlmeans/planning'
|
|
6
|
+
import { definitionProtocols, makeSuite, STORY_FLOW } from './context.js'
|
|
7
|
+
|
|
8
|
+
/** A choir's rehearsal card, defined as data for one project. */
|
|
9
|
+
const REHEARSAL: WorkcardTypeSchema = {
|
|
10
|
+
type: 'test:rehearsal', kind: WorkcardKind.Card, version: 1, fields: { type: 'object' }, flows: [STORY_FLOW], specifications: [],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('@owlmeans/client-planning — data-defined schemas', () => {
|
|
14
|
+
test('a layer is read once per project and read again after any write', async () => {
|
|
15
|
+
const suite = await makeSuite({ definitions: true })
|
|
16
|
+
const project = await suite.project('Spring concert')
|
|
17
|
+
const definitions = suite.planning.definitions!
|
|
18
|
+
const reads = () => suite.calls.filter(call => call.alias === definitionProtocols.schema.list.alias).length
|
|
19
|
+
|
|
20
|
+
const written = await definitions.define({ types: [REHEARSAL] }, { project: project.id })
|
|
21
|
+
expect(written.map(record => [record.kind, record.key, record.version, record.project]))
|
|
22
|
+
.toEqual([[PlanningSchemaKind.Type, REHEARSAL.type, 1, project.id]])
|
|
23
|
+
|
|
24
|
+
const before = reads()
|
|
25
|
+
const layer = await definitions.registry(project.id)
|
|
26
|
+
await definitions.registry(project.id)
|
|
27
|
+
expect(reads()).toBe(before + 1)
|
|
28
|
+
expect(layer.originOf(PlanningSchemaKind.Type, REHEARSAL.type)).toBe(SchemaOrigin.Project)
|
|
29
|
+
expect((await definitions.bundle()).types.some(type => type.type === REHEARSAL.type)).toBe(false)
|
|
30
|
+
|
|
31
|
+
await definitions.putType({ ...REHEARSAL, version: 2, label: 'Rehearsal' }, { project: project.id })
|
|
32
|
+
expect((await definitions.registry(project.id)).type(REHEARSAL.type).label).toBe('Rehearsal')
|
|
33
|
+
expect(reads()).toBe(before + 3)
|
|
34
|
+
await expect(definitions.records()).rejects.toBeInstanceOf(PlanningUnsupported)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('a model of a card resolves its type in its own project\'s layer', async () => {
|
|
38
|
+
const suite = await makeSuite({ definitions: true })
|
|
39
|
+
const project = await suite.project('Autumn concert')
|
|
40
|
+
await suite.planning.definitions!.define({ types: [REHEARSAL] }, { project: project.id })
|
|
41
|
+
const card = (await suite.local.execute({
|
|
42
|
+
card: { kind: WorkcardKind.Card, type: REHEARSAL.type, parent: project.id!, title: 'Sectionals' },
|
|
43
|
+
action: TransitionAction.Create,
|
|
44
|
+
}, { wait: true })).card!
|
|
45
|
+
|
|
46
|
+
const model = await suite.planning.model(card.id!)
|
|
47
|
+
expect(model.schema().type).toBe(REHEARSAL.type)
|
|
48
|
+
expect(model.available().map(rule => rule.name)).toEqual(['start', 'reset'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('a tree declared without definitions has none', async () => {
|
|
52
|
+
const suite = await makeSuite()
|
|
53
|
+
expect(suite.planning.definitions).toBeUndefined()
|
|
54
|
+
expect(suite.client.planning().definitions).toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
})
|