@owlmeans/client-planning 0.1.18-rc.8 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -11
- package/agent-meta/manifest.json +62 -4
- package/agent-meta/skills/client-planning/SKILL.md +60 -78
- package/agent-meta/skills/client-planning/parts/aux-mirrors.md +61 -0
- package/agent-meta/skills/client-planning/parts/mirror.md +67 -0
- package/agent-meta/skills/client-planning/parts/validation.md +60 -0
- package/build/client.d.ts +1 -1
- package/build/client.d.ts.map +1 -1
- package/build/client.js +66 -14
- package/build/client.js.map +1 -1
- package/build/commits.d.ts +2 -13
- package/build/commits.d.ts.map +1 -1
- package/build/commits.js +190 -132
- package/build/commits.js.map +1 -1
- package/build/consts.d.ts +4 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +10 -4
- package/build/consts.js.map +1 -1
- package/build/definitions.d.ts +13 -0
- package/build/definitions.d.ts.map +1 -0
- package/build/definitions.js +70 -0
- package/build/definitions.js.map +1 -0
- package/build/events/types.d.ts +34 -0
- package/build/events/types.d.ts.map +1 -0
- package/build/events/types.js +2 -0
- package/build/events/types.js.map +1 -0
- package/build/events.d.ts +10 -21
- package/build/events.d.ts.map +1 -1
- package/build/events.js +104 -102
- package/build/events.js.map +1 -1
- package/build/facade.d.ts.map +1 -1
- package/build/facade.js +86 -49
- package/build/facade.js.map +1 -1
- package/build/feed.d.ts +4 -4
- package/build/feed.d.ts.map +1 -1
- package/build/feed.js +50 -27
- package/build/feed.js.map +1 -1
- package/build/helper/types.d.ts +15 -0
- package/build/helper/types.d.ts.map +1 -0
- package/build/helper/types.js +2 -0
- package/build/helper/types.js.map +1 -0
- package/build/helper.d.ts +7 -7
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +16 -8
- package/build/helper.js.map +1 -1
- package/build/index.d.ts +11 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +6 -0
- package/build/index.js.map +1 -1
- package/build/lifecycle/types.d.ts +27 -0
- package/build/lifecycle/types.d.ts.map +1 -0
- package/build/lifecycle/types.js +2 -0
- package/build/lifecycle/types.js.map +1 -0
- package/build/lifecycle.d.ts +3 -0
- package/build/lifecycle.d.ts.map +1 -0
- package/build/lifecycle.js +157 -0
- package/build/lifecycle.js.map +1 -0
- package/build/resource-feed.d.ts +5 -0
- package/build/resource-feed.d.ts.map +1 -0
- package/build/resource-feed.js +97 -0
- package/build/resource-feed.js.map +1 -0
- package/build/resources.d.ts +6 -0
- package/build/resources.d.ts.map +1 -0
- package/build/resources.js +93 -0
- package/build/resources.js.map +1 -0
- package/build/stores.d.ts +4 -11
- package/build/stores.d.ts.map +1 -1
- package/build/stores.js +11 -35
- package/build/stores.js.map +1 -1
- package/build/sync/types.d.ts +18 -0
- package/build/sync/types.d.ts.map +1 -0
- package/build/sync/types.js +2 -0
- package/build/sync/types.js.map +1 -0
- package/build/sync.d.ts +4 -0
- package/build/sync.d.ts.map +1 -0
- package/build/sync.js +26 -0
- package/build/sync.js.map +1 -0
- package/build/types.d.ts +48 -3
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +13 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/record/types.d.ts +28 -0
- package/build/utils/record/types.d.ts.map +1 -0
- package/build/utils/record/types.js +2 -0
- package/build/utils/record/types.js.map +1 -0
- package/build/utils/record.d.ts +3 -24
- package/build/utils/record.d.ts.map +1 -1
- package/build/utils/record.js +64 -70
- package/build/utils/record.js.map +1 -1
- package/build/validation/consts.d.ts +23 -0
- package/build/validation/consts.d.ts.map +1 -0
- package/build/validation/consts.js +289 -0
- package/build/validation/consts.js.map +1 -0
- package/build/validation/types.d.ts +17 -0
- package/build/validation/types.d.ts.map +1 -0
- package/build/validation/types.js +2 -0
- package/build/validation/types.js.map +1 -0
- package/build/validation.d.ts +10 -0
- package/build/validation.d.ts.map +1 -0
- package/build/validation.js +171 -0
- package/build/validation.js.map +1 -0
- package/package.json +20 -18
- package/src/client.ts +58 -15
- package/src/commits.ts +152 -184
- package/src/consts.ts +12 -4
- package/src/definitions.ts +93 -0
- package/src/events/types.ts +35 -0
- package/src/events.ts +118 -108
- package/src/facade.ts +87 -55
- package/src/feed.ts +47 -28
- package/src/helper/types.ts +15 -0
- package/src/helper.ts +27 -10
- package/src/index.ts +12 -0
- package/src/lifecycle/types.ts +28 -0
- package/src/lifecycle.ts +123 -0
- package/src/resource-feed.ts +79 -0
- package/src/resources.ts +84 -0
- package/src/stores.ts +11 -40
- package/src/sync/types.ts +18 -0
- package/src/sync.ts +40 -0
- package/src/types.local.ts +14 -0
- package/src/types.ts +53 -5
- package/src/utils/record/types.ts +27 -0
- package/src/utils/record.ts +68 -71
- package/src/validation/consts.ts +289 -0
- package/src/validation/types.ts +18 -0
- package/src/validation.ts +140 -0
- package/tests/app.spec.ts +72 -0
- package/tests/context.ts +46 -15
- package/tests/csp.spec.ts +119 -0
- package/tests/definitions.spec.ts +74 -0
- package/tests/draft7.spec.ts +13 -0
- package/tests/events.spec.ts +13 -9
- package/tests/facade.spec.ts +2 -1
- package/tests/lifecycle/context.ts +29 -0
- package/tests/lifecycle-close.spec.ts +103 -0
- package/tests/lifecycle-commits.spec.ts +92 -0
- package/tests/lifecycle-definitions.spec.ts +28 -0
- package/tests/lifecycle-feeds.spec.ts +92 -0
- package/tests/lifecycle-sockets.spec.ts +76 -0
- package/tests/replies.spec.ts +81 -0
- package/tests/resources.spec.ts +116 -0
- package/tests/stores.spec.ts +13 -11
- package/tests/validation.spec.ts +127 -0
package/src/client.ts
CHANGED
|
@@ -2,12 +2,14 @@ import { bindAll } from '@owlmeans/client-entrypoint'
|
|
|
2
2
|
import { createLazyService, MiddlewareStage, MiddlewareType } from '@owlmeans/context'
|
|
3
3
|
import type { EntrypointTree } from '@owlmeans/entrypoint'
|
|
4
4
|
import {
|
|
5
|
-
DEFAULT_PLUGIN_ORDER, makeSchemaRegistry, PLANNING_SERVICE, PlanningUnsupported,
|
|
5
|
+
appSchemaHelper, DEFAULT_PLUGIN_ORDER, makeSchemaRegistry, PLANNING_SERVICE, PlanningUnsupported,
|
|
6
6
|
} from '@owlmeans/planning'
|
|
7
|
-
import type { PlanningFacade, PlanningPlugin, PlanningSchemaRegistry } from '@owlmeans/planning'
|
|
7
|
+
import type { PlanningAppDeclarations, PlanningFacade, PlanningPlugin, PlanningSchemaRegistry } from '@owlmeans/planning'
|
|
8
8
|
import { makeRemoteCommitSource } from './commits.js'
|
|
9
|
+
import { makeRemoteDefinitions } from './definitions.js'
|
|
9
10
|
import { makeRemoteFacade } from './facade.js'
|
|
10
|
-
import {
|
|
11
|
+
import { planningContextOf } from './helper.js'
|
|
12
|
+
import { makePlanningClientLifecycle } from './lifecycle.js'
|
|
11
13
|
import type {
|
|
12
14
|
Config, Context, PlanningClientOptions, PlanningClientService, WithPlanningClient,
|
|
13
15
|
} from './types.js'
|
|
@@ -19,40 +21,60 @@ import type {
|
|
|
19
21
|
* What only a server can do is refused rather than faked: a client runs no middleware and folds
|
|
20
22
|
* nothing, so `store()`, `committed()` and a plugin carrying hooks or a store answer
|
|
21
23
|
* `PlanningUnsupported`. A plugin carrying only schemas is accepted — it layers over the server's
|
|
22
|
-
* bundle.
|
|
24
|
+
* bundle (its `schemas.app` merged over the served declarations, in plugin order).
|
|
23
25
|
*/
|
|
24
26
|
export const makePlanningClientService = <C extends Config, T extends Context<C>>(
|
|
25
27
|
context: T, options: PlanningClientOptions
|
|
26
28
|
): PlanningClientService => {
|
|
29
|
+
const feeds = new Set<import('./types.js').PlanningFeed>()
|
|
27
30
|
const registry = makeSchemaRegistry()
|
|
28
31
|
const plugins: PlanningPlugin[] = []
|
|
29
|
-
const stores = () =>
|
|
32
|
+
const stores = () => planningContextOf(context).stores()
|
|
33
|
+
const lifecycle = makePlanningClientLifecycle({ scopeKey: options.scopeKey, clear: async () => {
|
|
34
|
+
const mirrors = stores()
|
|
35
|
+
if (mirrors != null) for (const resource of Object.values(mirrors)) {
|
|
36
|
+
const rows = await resource.list({}, { size: 0 })
|
|
37
|
+
for (const row of rows.items) await resource.delete(row.id!)
|
|
38
|
+
}
|
|
39
|
+
} })
|
|
40
|
+
const scopeKey = lifecycle.key
|
|
30
41
|
|
|
31
42
|
const commits = makeRemoteCommitSource(context, options.protocols, {
|
|
32
|
-
socket: options.socket, poll: options.poll, timeout: options.timeout, stores,
|
|
43
|
+
socket: options.socket, poll: options.poll, timeout: options.timeout, stores, lifecycle,
|
|
33
44
|
})
|
|
34
45
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
// The application's declarations the server served — what every client plugin's `app` merges over.
|
|
47
|
+
let served: PlanningAppDeclarations | undefined
|
|
48
|
+
const layerPlugins = (): void => {
|
|
49
|
+
registry.registerApp(appSchemaHelper.merge([served, ...plugins.map(plugin => plugin.schemas?.app)]))
|
|
50
|
+
plugins.forEach(plugin => {
|
|
51
|
+
plugin.schemas?.assigneeTypes?.forEach(registry.registerAssigneeType)
|
|
52
|
+
plugin.schemas?.flows?.forEach(registry.registerFlow)
|
|
53
|
+
plugin.schemas?.types?.forEach(registry.registerType)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
39
56
|
|
|
57
|
+
let loadedKey = scopeKey()
|
|
40
58
|
let loaded = false
|
|
41
59
|
let loading: Promise<PlanningSchemaRegistry> | null = null
|
|
42
60
|
|
|
43
61
|
const loadSchemas: PlanningClientService['loadSchemas'] = async opts => {
|
|
62
|
+
const key = scopeKey()
|
|
63
|
+
if (loadedKey !== key) { loaded = false; loading = null; definitions?.invalidate(); loadedKey = key }
|
|
44
64
|
if (loaded && opts?.force !== true) {
|
|
45
65
|
return registry
|
|
46
66
|
}
|
|
47
67
|
if (loading == null || opts?.force === true) {
|
|
48
|
-
const attempt = (async
|
|
49
|
-
const bundle = await context.entrypoint(options.protocols.schema.list).call({ timeout: options.timeout })
|
|
68
|
+
const attempt = lifecycle.run(async operation => {
|
|
69
|
+
const bundle = await operation.wait(context.entrypoint(options.protocols.schema.list).call({ timeout: options.timeout, signal: operation.signal }))
|
|
70
|
+
operation.check()
|
|
50
71
|
registry.load(bundle)
|
|
72
|
+
served = registry.app()
|
|
51
73
|
layerPlugins()
|
|
52
74
|
loaded = true
|
|
53
75
|
|
|
54
76
|
return registry
|
|
55
|
-
})
|
|
77
|
+
})
|
|
56
78
|
loading = attempt
|
|
57
79
|
// A failed load is forgotten, so the next caller asks again instead of inheriting the error.
|
|
58
80
|
attempt.catch(() => {
|
|
@@ -65,16 +87,32 @@ export const makePlanningClientService = <C extends Config, T extends Context<C>
|
|
|
65
87
|
return await loading
|
|
66
88
|
}
|
|
67
89
|
|
|
90
|
+
// A write of data-defined schemas moves the organization's bundle too: the next `model()` reloads it.
|
|
91
|
+
const definitions = options.protocols.schema.define == null
|
|
92
|
+
? undefined
|
|
93
|
+
: makeRemoteDefinitions(context, options.protocols, { lifecycle, timeout: options.timeout, onWrite: () => { loaded = false; loading = null } })
|
|
94
|
+
|
|
68
95
|
const facadeFor = (scope: Parameters<PlanningClientService['for']>[0]): PlanningFacade =>
|
|
69
96
|
makeRemoteFacade(context, options.protocols, { ...options.scope, ...scope }, {
|
|
70
|
-
commits, schemas: registry, loadSchemas, timeout: options.timeout, stores,
|
|
97
|
+
commits, schemas: registry, loadSchemas, scopeKey, lifecycle, timeout: options.timeout, stores,
|
|
98
|
+
...(definitions != null ? { definitions } : {}),
|
|
71
99
|
})
|
|
72
100
|
|
|
73
101
|
let fallback: PlanningFacade | null = null
|
|
102
|
+
lifecycle.onInvalidate(async () => {
|
|
103
|
+
const stopping = [...feeds]
|
|
104
|
+
feeds.clear()
|
|
105
|
+
loaded = false; loading = null; definitions?.invalidate()
|
|
106
|
+
registry.load({ version: 1, types: [], flows: [] }); served = undefined; layerPlugins()
|
|
107
|
+
await Promise.all(stopping.map(async feed => await feed.stop()))
|
|
108
|
+
})
|
|
74
109
|
|
|
75
110
|
// Lazy, like the server's host: `context.planning()` answers before the context initializes.
|
|
76
111
|
return createLazyService<PlanningClientService>(PLANNING_SERVICE, {
|
|
77
112
|
schemas: registry,
|
|
113
|
+
lifecycle,
|
|
114
|
+
scopeKey,
|
|
115
|
+
registerFeed: feed => { feeds.add(feed); return () => { feeds.delete(feed) } },
|
|
78
116
|
|
|
79
117
|
commits,
|
|
80
118
|
|
|
@@ -84,6 +122,9 @@ export const makePlanningClientService = <C extends Config, T extends Context<C>
|
|
|
84
122
|
if (plugin.before != null || plugin.after != null || plugin.store != null || plugin.mintCode != null) {
|
|
85
123
|
throw new PlanningUnsupported(`client:plugin:${plugin.name}`)
|
|
86
124
|
}
|
|
125
|
+
if (plugin.schemas?.app != null) {
|
|
126
|
+
appSchemaHelper.assertDeclarations(plugin.schemas.app)
|
|
127
|
+
}
|
|
87
128
|
const existing = plugins.findIndex(candidate => candidate.name === plugin.name)
|
|
88
129
|
if (existing >= 0) {
|
|
89
130
|
plugins.splice(existing, 1)
|
|
@@ -105,7 +146,9 @@ export const makePlanningClientService = <C extends Config, T extends Context<C>
|
|
|
105
146
|
throw new PlanningUnsupported('client:committed')
|
|
106
147
|
},
|
|
107
148
|
|
|
108
|
-
close:
|
|
149
|
+
close: lifecycle.close,
|
|
150
|
+
|
|
151
|
+
...(definitions != null ? { definitions } : {}),
|
|
109
152
|
})
|
|
110
153
|
}
|
|
111
154
|
|
package/src/commits.ts
CHANGED
|
@@ -1,247 +1,215 @@
|
|
|
1
1
|
import type { BasicConfig, BasicContext } from '@owlmeans/context'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
PLANNING_COMMIT_EVENT, PlanningError, WorkcardKind,
|
|
5
|
-
} from '@owlmeans/planning'
|
|
6
|
-
import type {
|
|
7
|
-
CommitEvent, CommitFilter, CommitStatus, PlanningProtocols, Unsubscribe, Workcard,
|
|
8
|
-
} from '@owlmeans/planning'
|
|
2
|
+
import { logger } from '@owlmeans/log'
|
|
3
|
+
import { planningReplyHelper, CommitFailed, CommitState, CommitTimeout, DEFAULT_COMMIT_POLL, DEFAULT_COMMIT_TIMEOUT, MAX_COMMIT_POLL, PLANNING_COMMIT_EVENT, PlanningError, WorkcardKind, type CommitEvent, type CommitFilter, type CommitStatus, type PlanningProtocols, type Unsubscribe, type Workcard } from '@owlmeans/planning'
|
|
9
4
|
import type { Connection } from '@owlmeans/socket'
|
|
10
5
|
import { EARLY_POLL_LADDER, EARLY_POLL_MS, LONG_POLL_GRACE } from './consts.js'
|
|
6
|
+
import { makePlanningClientLifecycle } from './lifecycle.js'
|
|
7
|
+
import type { PlanningOperation } from './lifecycle/types.js'
|
|
11
8
|
import type { RemoteCommitSource, RemoteCommitSourceOptions } from './types.js'
|
|
12
|
-
import {
|
|
9
|
+
import type { CommitSocketOpening, Subscription } from './types.local.js'
|
|
10
|
+
import { recordUtils } from './utils/record.js'
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
listener: (event: CommitEvent) => void | Promise<void>
|
|
16
|
-
filter?: CommitFilter
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const matches = (filter: CommitFilter | undefined, event: CommitEvent): boolean =>
|
|
20
|
-
filter == null || (
|
|
21
|
-
(filter.entityId == null || filter.entityId === event.entityId)
|
|
22
|
-
&& (filter.project == null || filter.project === event.project)
|
|
23
|
-
&& (filter.card == null || filter.card === event.card)
|
|
24
|
-
&& (filter.kind == null || filter.kind === event.kind)
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
const delay = (ms: number): { promise: Promise<void>, cancel: () => void } => {
|
|
28
|
-
let handle: ReturnType<typeof setTimeout> | undefined
|
|
29
|
-
const promise = new Promise<void>(resolve => { handle = setTimeout(resolve, Math.max(0, ms)) })
|
|
12
|
+
const log = logger('client-planning')
|
|
30
13
|
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The commit source of a remote facade: the `commit.get` long poll and, when the host injects a
|
|
36
|
-
* socket opener, the `commit.events` feed.
|
|
37
|
-
*
|
|
38
|
-
* `wait` SUBSCRIBES FIRST and only then reads the status — a commit landing between a read and a
|
|
39
|
-
* subscription would otherwise be missed until the deadline. It then long-polls in holds of
|
|
40
|
-
* `min(poll, remaining)` seconds, so a client with no socket (a Node connector) still learns of a
|
|
41
|
-
* commit within one round trip of it landing, and a client with one learns from whichever arrives
|
|
42
|
-
* first. The deadline is a {@link CommitTimeout}; the transition stays pending — nothing is undone.
|
|
43
|
-
*
|
|
44
|
-
* ONE socket serves every subscription of the source; releasing a subscription never closes it.
|
|
45
|
-
*/
|
|
14
|
+
/** Subscribe before reading, then race the shared socket against bounded HTTP holds. */
|
|
46
15
|
export const makeRemoteCommitSource = <C extends BasicConfig, T extends BasicContext<C>>(
|
|
47
16
|
context: T, protocols: PlanningProtocols, opts?: RemoteCommitSourceOptions
|
|
48
17
|
): RemoteCommitSource => {
|
|
18
|
+
const lifecycle = opts?.lifecycle ?? makePlanningClientLifecycle({ scopeKey: opts?.scopeKey })
|
|
49
19
|
const poll = Math.max(0, Math.min(MAX_COMMIT_POLL, opts?.poll ?? DEFAULT_COMMIT_POLL))
|
|
50
20
|
const subscriptions = new Set<Subscription>()
|
|
51
|
-
|
|
52
|
-
let opening:
|
|
21
|
+
const operations = new Set<PlanningOperation>()
|
|
22
|
+
let opening: CommitSocketOpening | null = null
|
|
23
|
+
const openingOf = (): CommitSocketOpening | null => opening
|
|
53
24
|
let open: Connection | null = null
|
|
54
25
|
let detach: (() => void) | null = null
|
|
55
26
|
|
|
27
|
+
const capture = (): PlanningOperation => {
|
|
28
|
+
const captured = lifecycle.capture()
|
|
29
|
+
const operation: PlanningOperation = { ...captured, release: () => {
|
|
30
|
+
operations.delete(operation)
|
|
31
|
+
captured.release()
|
|
32
|
+
} }
|
|
33
|
+
operations.add(operation)
|
|
34
|
+
return operation
|
|
35
|
+
}
|
|
36
|
+
const matches = (filter: CommitFilter | undefined, event: CommitEvent): boolean => filter == null || (
|
|
37
|
+
(filter.entityId == null || filter.entityId === event.entityId)
|
|
38
|
+
&& (filter.project == null || filter.project === event.project)
|
|
39
|
+
&& (filter.card == null || filter.card === event.card)
|
|
40
|
+
&& (filter.kind == null || filter.kind === event.kind)
|
|
41
|
+
)
|
|
42
|
+
const delay = (ms: number): { promise: Promise<void>, cancel: () => void } => {
|
|
43
|
+
let handle: ReturnType<typeof setTimeout> | undefined
|
|
44
|
+
const promise = new Promise<void>(resolve => { handle = setTimeout(resolve, Math.max(0, ms)) })
|
|
45
|
+
return { promise, cancel: () => clearTimeout(handle) }
|
|
46
|
+
}
|
|
56
47
|
const dispatch = async (event: CommitEvent): Promise<void> => {
|
|
57
48
|
for (const subscription of [...subscriptions]) {
|
|
58
|
-
if (!matches(subscription.filter, event))
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
try {
|
|
62
|
-
await subscription.listener(event)
|
|
63
|
-
} catch (e) {
|
|
64
|
-
console.error('Planning commit listener error:', e)
|
|
65
|
-
}
|
|
49
|
+
if (!subscriptions.has(subscription) || !subscription.operation.active() || !matches(subscription.filter, event)) continue
|
|
50
|
+
try { await subscription.operation.wait(Promise.resolve(subscription.listener(event))) }
|
|
51
|
+
catch (error) { if (subscription.operation.active()) log.error('Planning commit listener failed', error) }
|
|
66
52
|
}
|
|
67
53
|
}
|
|
68
54
|
|
|
69
55
|
const socket = async (): Promise<Connection | null> => {
|
|
70
56
|
const opener = opts?.socket
|
|
71
|
-
if (opener == null)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
opening
|
|
57
|
+
if (opener == null) return null
|
|
58
|
+
lifecycle.key() // Observe an identity change before reusing an open carrier.
|
|
59
|
+
if (open != null) return open
|
|
60
|
+
if (opening != null) return await opening.promise
|
|
61
|
+
const operation = capture()
|
|
62
|
+
const pending = (async () => {
|
|
75
63
|
try {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return null
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
64
|
+
const carrier = Promise.resolve().then(async () => {
|
|
65
|
+
operation.check()
|
|
66
|
+
return await opener(protocols.commit.events, { query: {}, signal: operation.signal })
|
|
67
|
+
}).then(async connection => {
|
|
68
|
+
if (connection == null) return null
|
|
69
|
+
if (!operation.active()) { await connection.close(); return null }
|
|
70
|
+
open = connection
|
|
71
|
+
detach = connection.observe<CommitEvent>(PLANNING_COMMIT_EVENT, async message => {
|
|
72
|
+
if (operation.active()) await dispatch(planningReplyHelper.hydrate<CommitEvent>(message.payload))
|
|
73
|
+
})
|
|
74
|
+
return connection
|
|
85
75
|
})
|
|
86
|
-
return
|
|
87
|
-
} catch (
|
|
88
|
-
|
|
89
|
-
console.error('Planning commit socket error:', e)
|
|
76
|
+
return await operation.wait(carrier)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (operation.active()) log.warn('Planning commit socket failed to open', error)
|
|
90
79
|
return null
|
|
80
|
+
} finally {
|
|
81
|
+
if (openingOf()?.operation === operation) opening = null
|
|
82
|
+
// Keep an open carrier's operation cancellable until close; it guards received frames.
|
|
83
|
+
if (open == null || !operation.active()) operation.release()
|
|
91
84
|
}
|
|
92
85
|
})()
|
|
93
|
-
|
|
94
|
-
return await
|
|
86
|
+
opening = { operation, promise: pending }
|
|
87
|
+
return await pending
|
|
95
88
|
}
|
|
96
89
|
|
|
97
90
|
const endpoint = () => context.entrypoint(protocols.commit.get)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
91
|
+
const status = async (transition: string, operation: PlanningOperation): Promise<CommitStatus> => {
|
|
92
|
+
operation.check()
|
|
93
|
+
return planningReplyHelper.hydrate<CommitStatus>(await operation.wait(endpoint().call({
|
|
94
|
+
params: { transition }, query: { wait: 0 }, timeout: opts?.timeout, signal: operation.signal,
|
|
95
|
+
})))
|
|
96
|
+
}
|
|
97
|
+
const hold = async (transition: string, wait: number, signal: AbortSignal, operation: PlanningOperation): Promise<CommitStatus> => {
|
|
98
|
+
operation.check()
|
|
104
99
|
try {
|
|
105
|
-
return await endpoint().call({
|
|
100
|
+
return planningReplyHelper.hydrate<CommitStatus>(await operation.wait(endpoint().call({
|
|
106
101
|
params: { transition }, query: { wait }, timeout: wait * 1000 + LONG_POLL_GRACE, signal,
|
|
107
|
-
})
|
|
108
|
-
} catch (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// non-holding read replaces it, and a failure of THAT is reported.
|
|
114
|
-
return await status(transition)
|
|
102
|
+
})))
|
|
103
|
+
} catch (error) {
|
|
104
|
+
operation.check()
|
|
105
|
+
if (signal.aborted || error instanceof PlanningError) throw error
|
|
106
|
+
// A dropped hold says nothing about the commit. Never retry it under another scope.
|
|
107
|
+
return await status(transition, operation)
|
|
115
108
|
}
|
|
116
109
|
}
|
|
117
|
-
|
|
118
|
-
/** Fold a settled status into the mirror, when the host registered one. */
|
|
119
|
-
const fold = async (transition: string, settled: CommitStatus): Promise<void> => {
|
|
110
|
+
const answer = async (transition: string, settled: CommitStatus, operation: PlanningOperation): Promise<Workcard | null> => {
|
|
120
111
|
const stores = opts?.stores?.()
|
|
121
|
-
if (stores
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
112
|
+
if (stores != null) await lifecycle.mutate(operation, async () => {
|
|
113
|
+
const known = await stores.commits.load(transition)
|
|
114
|
+
await recordUtils.putCommit(stores.commits, {
|
|
115
|
+
id: transition, state: settled.state, at: settled.at, error: settled.error,
|
|
116
|
+
})
|
|
117
|
+
if (settled.state === CommitState.Committed) {
|
|
118
|
+
if (settled.card != null) await recordUtils.putCard(stores.cards, settled.card)
|
|
119
|
+
else if (settled.card === null && known?.card != null) await recordUtils.dropCard(stores, known.card, known.kind === WorkcardKind.Project)
|
|
120
|
+
}
|
|
127
121
|
})
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
if (settled.card != null) {
|
|
132
|
-
await putCard(stores.cards, settled.card)
|
|
133
|
-
} else if (settled.card === null && known?.card != null) {
|
|
134
|
-
await dropCard(stores, known.card, known.kind === WorkcardKind.Project)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const answer = async (transition: string, settled: CommitStatus): Promise<Workcard | null> => {
|
|
139
|
-
await fold(transition, settled)
|
|
140
|
-
if (settled.state === CommitState.Failed) {
|
|
141
|
-
throw new CommitFailed(settled.error ?? transition)
|
|
142
|
-
}
|
|
143
|
-
|
|
122
|
+
operation.check()
|
|
123
|
+
if (settled.state === CommitState.Failed) throw new CommitFailed(settled.error ?? transition)
|
|
144
124
|
return settled.card ?? null
|
|
145
125
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
): Promise<Unsubscribe> => {
|
|
150
|
-
const subscription: Subscription = { listener, filter }
|
|
126
|
+
const subscribe = async (listener: Subscription['listener'], filter?: CommitFilter, signal?: AbortSignal): Promise<Unsubscribe> => {
|
|
127
|
+
const operation = capture()
|
|
128
|
+
const subscription: Subscription = { listener, filter, operation }
|
|
151
129
|
subscriptions.add(subscription)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
130
|
+
const stop = (): void => {
|
|
131
|
+
subscriptions.delete(subscription); operation.cancel(); operation.release()
|
|
132
|
+
signal?.removeEventListener('abort', stop)
|
|
133
|
+
}
|
|
134
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
135
|
+
if (signal?.aborted) stop()
|
|
136
|
+
try { await operation.wait(socket()); return stop }
|
|
137
|
+
catch (error) { stop(); throw error }
|
|
155
138
|
}
|
|
139
|
+
const close = async (): Promise<void> => {
|
|
140
|
+
for (const operation of operations) { operation.cancel(); operation.release() }
|
|
141
|
+
subscriptions.clear()
|
|
142
|
+
detach?.(); detach = null
|
|
143
|
+
const connection = open
|
|
144
|
+
open = null; opening = null
|
|
145
|
+
await Promise.all([connection?.close(), lifecycle.drain()])
|
|
146
|
+
}
|
|
147
|
+
lifecycle.onInvalidate(close)
|
|
156
148
|
|
|
157
|
-
|
|
158
|
-
status
|
|
159
|
-
|
|
149
|
+
return {
|
|
150
|
+
status: async transition => {
|
|
151
|
+
const operation = capture()
|
|
152
|
+
try { return await status(transition, operation) } finally { operation.release() }
|
|
153
|
+
},
|
|
160
154
|
subscribe,
|
|
161
|
-
|
|
162
155
|
wait: async (transition, waitOpts) => {
|
|
163
|
-
const
|
|
164
|
-
|
|
156
|
+
const operation = capture()
|
|
157
|
+
const duration = waitOpts?.timeout ?? DEFAULT_COMMIT_TIMEOUT
|
|
158
|
+
const deadline = Date.now() + duration
|
|
159
|
+
const deadlineTimer = setTimeout(() => operation.cancel(new CommitTimeout(transition)), Math.max(0, duration))
|
|
165
160
|
const frame: { landed: CommitEvent | null } = { landed: null }
|
|
166
|
-
let wake: () => void = () => {
|
|
167
|
-
|
|
168
|
-
if (event.transition === transition && event.state !== CommitState.Pending) {
|
|
169
|
-
frame.landed = event
|
|
170
|
-
wake()
|
|
171
|
-
}
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
/** A frame says it settled; the status read carries the card when the frame does not. */
|
|
175
|
-
const fromFrame = async (event: CommitEvent): Promise<Workcard | null> => event.record !== undefined
|
|
176
|
-
? await answer(transition, {
|
|
177
|
-
transition, state: event.state, at: event.at, error: event.error, card: event.record,
|
|
178
|
-
})
|
|
179
|
-
: await answer(transition, await status(transition))
|
|
180
|
-
|
|
161
|
+
let wake: () => void = () => {}
|
|
162
|
+
let unsubscribe: Unsubscribe | undefined
|
|
181
163
|
try {
|
|
182
|
-
|
|
164
|
+
const subscribing = subscribe(event => {
|
|
165
|
+
if (operation.active() && event.transition === transition && event.state !== CommitState.Pending) { frame.landed = event; wake() }
|
|
166
|
+
}, undefined, operation.signal)
|
|
167
|
+
// A cancelled waiter must also release a subscription whose opener completes later.
|
|
168
|
+
void subscribing.then(stop => { if (!operation.active()) stop() }, () => {})
|
|
169
|
+
unsubscribe = await operation.wait(subscribing)
|
|
170
|
+
const fromFrame = async (event: CommitEvent): Promise<Workcard | null> => await answer(transition,
|
|
171
|
+
event.record !== undefined
|
|
172
|
+
? { transition, state: event.state, at: event.at, error: event.error, card: event.record }
|
|
173
|
+
: await status(transition, operation), operation)
|
|
174
|
+
let current = await status(transition, operation)
|
|
183
175
|
let early = 0
|
|
184
176
|
while (current.state === CommitState.Pending) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
177
|
+
operation.check()
|
|
178
|
+
if (frame.landed != null) return await fromFrame(frame.landed)
|
|
188
179
|
const remaining = deadline - Date.now()
|
|
189
|
-
if (remaining <= 0)
|
|
190
|
-
throw new CommitTimeout(transition)
|
|
191
|
-
}
|
|
192
|
-
|
|
180
|
+
if (remaining <= 0) throw new CommitTimeout(transition)
|
|
193
181
|
const wait = Math.min(poll, Math.ceil(remaining / 1000))
|
|
194
182
|
const abort = new AbortController()
|
|
183
|
+
const cancel = () => abort.abort(operation.signal.reason)
|
|
184
|
+
operation.signal.addEventListener('abort', cancel, { once: true })
|
|
195
185
|
const timer = delay(remaining)
|
|
196
186
|
const framed = new Promise<'frame'>(resolve => { wake = () => resolve('frame') })
|
|
197
187
|
const started = Date.now()
|
|
198
188
|
try {
|
|
199
|
-
const outcome = await Promise.race([
|
|
200
|
-
hold(transition, wait, abort.signal),
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
])
|
|
204
|
-
if (outcome === 'frame' || outcome === 'deadline') {
|
|
205
|
-
continue
|
|
206
|
-
}
|
|
189
|
+
const outcome = await operation.wait(Promise.race([
|
|
190
|
+
hold(transition, wait, abort.signal, operation), framed, timer.promise.then(() => 'deadline' as const),
|
|
191
|
+
]))
|
|
192
|
+
if (outcome === 'frame' || outcome === 'deadline') continue
|
|
207
193
|
current = outcome
|
|
208
194
|
} finally {
|
|
209
|
-
abort.abort()
|
|
210
|
-
|
|
211
|
-
wake = () => { }
|
|
195
|
+
abort.abort(); timer.cancel(); wake = () => {}
|
|
196
|
+
operation.signal.removeEventListener('abort', cancel)
|
|
212
197
|
}
|
|
213
|
-
|
|
214
198
|
if (current.state === CommitState.Pending && Date.now() - started < EARLY_POLL_MS) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
))
|
|
219
|
-
|
|
220
|
-
await Promise.race([pause.promise, new Promise<void>(resolve => { wake = resolve })])
|
|
221
|
-
pause.cancel()
|
|
222
|
-
wake = () => { }
|
|
223
|
-
} else {
|
|
224
|
-
early = 0
|
|
225
|
-
}
|
|
199
|
+
const pause = delay(Math.min(EARLY_POLL_LADDER[Math.min(early, EARLY_POLL_LADDER.length - 1)], deadline - Date.now()))
|
|
200
|
+
early++
|
|
201
|
+
try { await operation.wait(Promise.race([pause.promise, new Promise<void>(resolve => { wake = resolve })])) }
|
|
202
|
+
finally { pause.cancel(); wake = () => {} }
|
|
203
|
+
} else early = 0
|
|
226
204
|
}
|
|
227
|
-
|
|
228
|
-
return await answer(transition, current)
|
|
205
|
+
return await answer(transition, current, operation)
|
|
229
206
|
} finally {
|
|
230
|
-
|
|
207
|
+
clearTimeout(deadlineTimer)
|
|
208
|
+
unsubscribe?.()
|
|
209
|
+
operation.cancel(); operation.release()
|
|
231
210
|
}
|
|
232
211
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
close: async () => {
|
|
237
|
-
detach?.()
|
|
238
|
-
detach = null
|
|
239
|
-
const connection = open
|
|
240
|
-
open = null
|
|
241
|
-
opening = null
|
|
242
|
-
await connection?.close()
|
|
243
|
-
},
|
|
212
|
+
connected: () => { lifecycle.key(); return open != null },
|
|
213
|
+
close,
|
|
244
214
|
}
|
|
245
|
-
|
|
246
|
-
return source
|
|
247
215
|
}
|
package/src/consts.ts
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import type { Relationship, Workcard } from '@owlmeans/planning'
|
|
2
|
-
import { stateAlias } from '@owlmeans/state'
|
|
3
2
|
import type { StateAlias } from '@owlmeans/state'
|
|
4
3
|
import type { PlanningCommitRecord, PlanningStoreAliases } from './types.js'
|
|
5
4
|
|
|
5
|
+
// A state alias is the string itself at runtime (`stateAlias()` only attaches the record type), so
|
|
6
|
+
// each handle is declared as the constant it is.
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* The ONE card store. Projects, cards and specifications share an id space on the server, so they
|
|
8
10
|
* share it here too — a store per kind would let a list reload of one kind drop another's rows.
|
|
9
11
|
*/
|
|
10
|
-
export const CARDS
|
|
12
|
+
export const CARDS = 'planning-card-state' as StateAlias<Workcard>
|
|
11
13
|
|
|
12
|
-
export const LINKS
|
|
14
|
+
export const LINKS = 'planning-link-state' as StateAlias<Relationship>
|
|
13
15
|
|
|
14
16
|
/** What the client has learned about each transition it wrote or saw, keyed by transition id. */
|
|
15
|
-
export const COMMITS
|
|
17
|
+
export const COMMITS = 'planning-commit-state' as StateAlias<PlanningCommitRecord>
|
|
18
|
+
|
|
19
|
+
export const ASSIGNEES = 'planning-assignee-state' as StateAlias<import('@owlmeans/planning').Assignee>
|
|
20
|
+
export const TEAMS = 'planning-team-state' as StateAlias<import('@owlmeans/planning').Team>
|
|
21
|
+
export const COMMENTS = 'planning-comment-state' as StateAlias<import('@owlmeans/planning').Comment>
|
|
22
|
+
export const MENTIONS = 'planning-mention-state' as StateAlias<import('@owlmeans/planning').CommentMention>
|
|
16
23
|
|
|
17
24
|
export const DEFAULT_STORE_ALIASES: Readonly<PlanningStoreAliases> = Object.freeze({
|
|
25
|
+
assignees: ASSIGNEES, teams: TEAMS, comments: COMMENTS, mentions: MENTIONS,
|
|
18
26
|
cards: CARDS,
|
|
19
27
|
links: LINKS,
|
|
20
28
|
commits: COMMITS,
|