@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.
Files changed (146) hide show
  1. package/README.md +94 -11
  2. package/agent-meta/manifest.json +62 -4
  3. package/agent-meta/skills/client-planning/SKILL.md +60 -78
  4. package/agent-meta/skills/client-planning/parts/aux-mirrors.md +61 -0
  5. package/agent-meta/skills/client-planning/parts/mirror.md +67 -0
  6. package/agent-meta/skills/client-planning/parts/validation.md +60 -0
  7. package/build/client.d.ts +1 -1
  8. package/build/client.d.ts.map +1 -1
  9. package/build/client.js +66 -14
  10. package/build/client.js.map +1 -1
  11. package/build/commits.d.ts +2 -13
  12. package/build/commits.d.ts.map +1 -1
  13. package/build/commits.js +190 -132
  14. package/build/commits.js.map +1 -1
  15. package/build/consts.d.ts +4 -0
  16. package/build/consts.d.ts.map +1 -1
  17. package/build/consts.js +10 -4
  18. package/build/consts.js.map +1 -1
  19. package/build/definitions.d.ts +13 -0
  20. package/build/definitions.d.ts.map +1 -0
  21. package/build/definitions.js +70 -0
  22. package/build/definitions.js.map +1 -0
  23. package/build/events/types.d.ts +34 -0
  24. package/build/events/types.d.ts.map +1 -0
  25. package/build/events/types.js +2 -0
  26. package/build/events/types.js.map +1 -0
  27. package/build/events.d.ts +10 -21
  28. package/build/events.d.ts.map +1 -1
  29. package/build/events.js +104 -102
  30. package/build/events.js.map +1 -1
  31. package/build/facade.d.ts.map +1 -1
  32. package/build/facade.js +86 -49
  33. package/build/facade.js.map +1 -1
  34. package/build/feed.d.ts +4 -4
  35. package/build/feed.d.ts.map +1 -1
  36. package/build/feed.js +50 -27
  37. package/build/feed.js.map +1 -1
  38. package/build/helper/types.d.ts +15 -0
  39. package/build/helper/types.d.ts.map +1 -0
  40. package/build/helper/types.js +2 -0
  41. package/build/helper/types.js.map +1 -0
  42. package/build/helper.d.ts +7 -7
  43. package/build/helper.d.ts.map +1 -1
  44. package/build/helper.js +16 -8
  45. package/build/helper.js.map +1 -1
  46. package/build/index.d.ts +11 -0
  47. package/build/index.d.ts.map +1 -1
  48. package/build/index.js +6 -0
  49. package/build/index.js.map +1 -1
  50. package/build/lifecycle/types.d.ts +27 -0
  51. package/build/lifecycle/types.d.ts.map +1 -0
  52. package/build/lifecycle/types.js +2 -0
  53. package/build/lifecycle/types.js.map +1 -0
  54. package/build/lifecycle.d.ts +3 -0
  55. package/build/lifecycle.d.ts.map +1 -0
  56. package/build/lifecycle.js +157 -0
  57. package/build/lifecycle.js.map +1 -0
  58. package/build/resource-feed.d.ts +5 -0
  59. package/build/resource-feed.d.ts.map +1 -0
  60. package/build/resource-feed.js +97 -0
  61. package/build/resource-feed.js.map +1 -0
  62. package/build/resources.d.ts +6 -0
  63. package/build/resources.d.ts.map +1 -0
  64. package/build/resources.js +93 -0
  65. package/build/resources.js.map +1 -0
  66. package/build/stores.d.ts +4 -11
  67. package/build/stores.d.ts.map +1 -1
  68. package/build/stores.js +11 -35
  69. package/build/stores.js.map +1 -1
  70. package/build/sync/types.d.ts +18 -0
  71. package/build/sync/types.d.ts.map +1 -0
  72. package/build/sync/types.js +2 -0
  73. package/build/sync/types.js.map +1 -0
  74. package/build/sync.d.ts +4 -0
  75. package/build/sync.d.ts.map +1 -0
  76. package/build/sync.js +26 -0
  77. package/build/sync.js.map +1 -0
  78. package/build/types.d.ts +48 -3
  79. package/build/types.d.ts.map +1 -1
  80. package/build/types.local.d.ts +13 -0
  81. package/build/types.local.d.ts.map +1 -0
  82. package/build/types.local.js +2 -0
  83. package/build/types.local.js.map +1 -0
  84. package/build/utils/record/types.d.ts +28 -0
  85. package/build/utils/record/types.d.ts.map +1 -0
  86. package/build/utils/record/types.js +2 -0
  87. package/build/utils/record/types.js.map +1 -0
  88. package/build/utils/record.d.ts +3 -24
  89. package/build/utils/record.d.ts.map +1 -1
  90. package/build/utils/record.js +64 -70
  91. package/build/utils/record.js.map +1 -1
  92. package/build/validation/consts.d.ts +23 -0
  93. package/build/validation/consts.d.ts.map +1 -0
  94. package/build/validation/consts.js +289 -0
  95. package/build/validation/consts.js.map +1 -0
  96. package/build/validation/types.d.ts +17 -0
  97. package/build/validation/types.d.ts.map +1 -0
  98. package/build/validation/types.js +2 -0
  99. package/build/validation/types.js.map +1 -0
  100. package/build/validation.d.ts +10 -0
  101. package/build/validation.d.ts.map +1 -0
  102. package/build/validation.js +171 -0
  103. package/build/validation.js.map +1 -0
  104. package/package.json +20 -18
  105. package/src/client.ts +58 -15
  106. package/src/commits.ts +152 -184
  107. package/src/consts.ts +12 -4
  108. package/src/definitions.ts +93 -0
  109. package/src/events/types.ts +35 -0
  110. package/src/events.ts +118 -108
  111. package/src/facade.ts +87 -55
  112. package/src/feed.ts +47 -28
  113. package/src/helper/types.ts +15 -0
  114. package/src/helper.ts +27 -10
  115. package/src/index.ts +12 -0
  116. package/src/lifecycle/types.ts +28 -0
  117. package/src/lifecycle.ts +123 -0
  118. package/src/resource-feed.ts +79 -0
  119. package/src/resources.ts +84 -0
  120. package/src/stores.ts +11 -40
  121. package/src/sync/types.ts +18 -0
  122. package/src/sync.ts +40 -0
  123. package/src/types.local.ts +14 -0
  124. package/src/types.ts +53 -5
  125. package/src/utils/record/types.ts +27 -0
  126. package/src/utils/record.ts +68 -71
  127. package/src/validation/consts.ts +289 -0
  128. package/src/validation/types.ts +18 -0
  129. package/src/validation.ts +140 -0
  130. package/tests/app.spec.ts +72 -0
  131. package/tests/context.ts +46 -15
  132. package/tests/csp.spec.ts +119 -0
  133. package/tests/definitions.spec.ts +74 -0
  134. package/tests/draft7.spec.ts +13 -0
  135. package/tests/events.spec.ts +13 -9
  136. package/tests/facade.spec.ts +2 -1
  137. package/tests/lifecycle/context.ts +29 -0
  138. package/tests/lifecycle-close.spec.ts +103 -0
  139. package/tests/lifecycle-commits.spec.ts +92 -0
  140. package/tests/lifecycle-definitions.spec.ts +28 -0
  141. package/tests/lifecycle-feeds.spec.ts +92 -0
  142. package/tests/lifecycle-sockets.spec.ts +76 -0
  143. package/tests/replies.spec.ts +81 -0
  144. package/tests/resources.spec.ts +116 -0
  145. package/tests/stores.spec.ts +13 -11
  146. 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 { planningStoresOf } from './stores.js'
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 = () => planningStoresOf(context)
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
- const layerPlugins = (): void => plugins.forEach(plugin => {
36
- plugin.schemas?.flows?.forEach(registry.registerFlow)
37
- plugin.schemas?.types?.forEach(registry.registerType)
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: async () => await commits.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
- CommitFailed, CommitState, CommitTimeout, DEFAULT_COMMIT_POLL, DEFAULT_COMMIT_TIMEOUT, MAX_COMMIT_POLL,
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 { dropCard, putCard, putCommit } from './utils/record.js'
9
+ import type { CommitSocketOpening, Subscription } from './types.local.js'
10
+ import { recordUtils } from './utils/record.js'
13
11
 
14
- interface Subscription {
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
- return { promise, cancel: () => clearTimeout(handle) }
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: Promise<Connection | null> | null = null
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
- continue
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
- return null
73
- }
74
- opening ??= (async () => {
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 connection = await opener(protocols.commit.events, { query: {} })
77
- if (connection == null) {
78
- // No socket right now (not signed in yet, say) — the next subscription asks again.
79
- opening = null
80
- return null
81
- }
82
- open = connection
83
- detach = connection.observe<CommitEvent>(PLANNING_COMMIT_EVENT, async message => {
84
- await dispatch(message.payload)
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 connection
87
- } catch (e) {
88
- opening = null
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 opening
86
+ opening = { operation, promise: pending }
87
+ return await pending
95
88
  }
96
89
 
97
90
  const endpoint = () => context.entrypoint(protocols.commit.get)
98
-
99
- const status = async (transition: string): Promise<CommitStatus> => await endpoint().call({
100
- params: { transition }, query: { wait: 0 }, timeout: opts?.timeout,
101
- })
102
-
103
- const hold = async (transition: string, wait: number, signal: AbortSignal): Promise<CommitStatus> => {
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 (e) {
109
- if (signal.aborted || e instanceof PlanningError) {
110
- throw e
111
- }
112
- // A dropped hold (a proxy closing an idle response) is not an answer about the commit: one
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 == null) {
122
- return
123
- }
124
- const known = await stores.commits.load(transition)
125
- await putCommit(stores.commits, {
126
- id: transition, state: settled.state, at: settled.at, error: settled.error,
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
- if (settled.state !== CommitState.Committed) {
129
- return
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
- const subscribe = async (
148
- listener: Subscription['listener'], filter?: CommitFilter
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
- await socket()
153
-
154
- return () => { subscriptions.delete(subscription) }
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
- const source: RemoteCommitSource = {
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 deadline = Date.now() + (waitOpts?.timeout ?? DEFAULT_COMMIT_TIMEOUT)
164
- // Held in an object: a frame writes it from a callback, which flow analysis cannot see.
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
- const unsubscribe = await subscribe(event => {
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
- let current = await status(transition)
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
- if (frame.landed != null) {
186
- return await fromFrame(frame.landed)
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
- framed,
202
- timer.promise.then(() => 'deadline' as const),
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
- timer.cancel()
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
- // The server did not hold — pause on a ladder rather than spin until the deadline.
216
- const pause = delay(Math.min(
217
- EARLY_POLL_LADDER[Math.min(early, EARLY_POLL_LADDER.length - 1)], deadline - Date.now()
218
- ))
219
- early += 1
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
- unsubscribe()
207
+ clearTimeout(deadlineTimer)
208
+ unsubscribe?.()
209
+ operation.cancel(); operation.release()
231
210
  }
232
211
  },
233
-
234
- connected: () => open != null,
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: StateAlias<Workcard> = stateAlias<Workcard>('planning-card-state')
12
+ export const CARDS = 'planning-card-state' as StateAlias<Workcard>
11
13
 
12
- export const LINKS: StateAlias<Relationship> = stateAlias<Relationship>('planning-link-state')
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: StateAlias<PlanningCommitRecord> = stateAlias<PlanningCommitRecord>('planning-commit-state')
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,