@hydranium/protocol 1.0.0-next.10

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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/lib/abstract-logger.d.ts +66 -0
  4. package/lib/abstract-logger.d.ts.map +1 -0
  5. package/lib/abstract-logger.js +110 -0
  6. package/lib/abstract-logger.js.map +1 -0
  7. package/lib/browser-runtime.d.ts +124 -0
  8. package/lib/browser-runtime.d.ts.map +1 -0
  9. package/lib/browser-runtime.js +11 -0
  10. package/lib/browser-runtime.js.map +1 -0
  11. package/lib/client/data-events.d.ts +49 -0
  12. package/lib/client/data-events.d.ts.map +1 -0
  13. package/lib/client/data-events.js +60 -0
  14. package/lib/client/data-events.js.map +1 -0
  15. package/lib/client/data-port.d.ts +95 -0
  16. package/lib/client/data-port.d.ts.map +1 -0
  17. package/lib/client/data-port.js +11 -0
  18. package/lib/client/data-port.js.map +1 -0
  19. package/lib/client/data-session.d.ts +129 -0
  20. package/lib/client/data-session.d.ts.map +1 -0
  21. package/lib/client/data-session.js +179 -0
  22. package/lib/client/data-session.js.map +1 -0
  23. package/lib/client/index.d.ts +39 -0
  24. package/lib/client/index.d.ts.map +1 -0
  25. package/lib/client/index.js +55 -0
  26. package/lib/client/index.js.map +1 -0
  27. package/lib/client/message-relay.d.ts +111 -0
  28. package/lib/client/message-relay.d.ts.map +1 -0
  29. package/lib/client/message-relay.js +139 -0
  30. package/lib/client/message-relay.js.map +1 -0
  31. package/lib/client/post-message-transport.d.ts +77 -0
  32. package/lib/client/post-message-transport.d.ts.map +1 -0
  33. package/lib/client/post-message-transport.js +95 -0
  34. package/lib/client/post-message-transport.js.map +1 -0
  35. package/lib/clock.d.ts +111 -0
  36. package/lib/clock.d.ts.map +1 -0
  37. package/lib/clock.js +74 -0
  38. package/lib/clock.js.map +1 -0
  39. package/lib/data/data-protocol-methods.d.ts +39 -0
  40. package/lib/data/data-protocol-methods.d.ts.map +1 -0
  41. package/lib/data/data-protocol-methods.js +61 -0
  42. package/lib/data/data-protocol-methods.js.map +1 -0
  43. package/lib/data/data-server-protocol.d.ts +264 -0
  44. package/lib/data/data-server-protocol.d.ts.map +1 -0
  45. package/lib/data/data-server-protocol.js +11 -0
  46. package/lib/data/data-server-protocol.js.map +1 -0
  47. package/lib/data/diagnostics.d.ts +97 -0
  48. package/lib/data/diagnostics.d.ts.map +1 -0
  49. package/lib/data/diagnostics.js +25 -0
  50. package/lib/data/diagnostics.js.map +1 -0
  51. package/lib/data/events.d.ts +107 -0
  52. package/lib/data/events.d.ts.map +1 -0
  53. package/lib/data/events.js +11 -0
  54. package/lib/data/events.js.map +1 -0
  55. package/lib/data/index.d.ts +15 -0
  56. package/lib/data/index.d.ts.map +1 -0
  57. package/lib/data/index.js +38 -0
  58. package/lib/data/index.js.map +1 -0
  59. package/lib/data/methods.d.ts +57 -0
  60. package/lib/data/methods.d.ts.map +1 -0
  61. package/lib/data/methods.js +60 -0
  62. package/lib/data/methods.js.map +1 -0
  63. package/lib/data/requests.d.ts +65 -0
  64. package/lib/data/requests.d.ts.map +1 -0
  65. package/lib/data/requests.js +11 -0
  66. package/lib/data/requests.js.map +1 -0
  67. package/lib/debouncer.d.ts +75 -0
  68. package/lib/debouncer.d.ts.map +1 -0
  69. package/lib/debouncer.js +91 -0
  70. package/lib/debouncer.js.map +1 -0
  71. package/lib/errors.d.ts +84 -0
  72. package/lib/errors.d.ts.map +1 -0
  73. package/lib/errors.js +113 -0
  74. package/lib/errors.js.map +1 -0
  75. package/lib/host-diagnostics.d.ts +58 -0
  76. package/lib/host-diagnostics.d.ts.map +1 -0
  77. package/lib/host-diagnostics.js +18 -0
  78. package/lib/host-diagnostics.js.map +1 -0
  79. package/lib/index.d.ts +34 -0
  80. package/lib/index.d.ts.map +1 -0
  81. package/lib/index.js +56 -0
  82. package/lib/index.js.map +1 -0
  83. package/lib/latency-collector.d.ts +126 -0
  84. package/lib/latency-collector.d.ts.map +1 -0
  85. package/lib/latency-collector.js +137 -0
  86. package/lib/latency-collector.js.map +1 -0
  87. package/lib/logger.d.ts +119 -0
  88. package/lib/logger.d.ts.map +1 -0
  89. package/lib/logger.js +126 -0
  90. package/lib/logger.js.map +1 -0
  91. package/lib/model-server.d.ts +352 -0
  92. package/lib/model-server.d.ts.map +1 -0
  93. package/lib/model-server.js +160 -0
  94. package/lib/model-server.js.map +1 -0
  95. package/lib/model-service/args.d.ts +64 -0
  96. package/lib/model-service/args.d.ts.map +1 -0
  97. package/lib/model-service/args.js +11 -0
  98. package/lib/model-service/args.js.map +1 -0
  99. package/lib/model-service/index.d.ts +11 -0
  100. package/lib/model-service/index.d.ts.map +1 -0
  101. package/lib/model-service/index.js +31 -0
  102. package/lib/model-service/index.js.map +1 -0
  103. package/lib/model-service/reference-candidate.d.ts +56 -0
  104. package/lib/model-service/reference-candidate.d.ts.map +1 -0
  105. package/lib/model-service/reference-candidate.js +11 -0
  106. package/lib/model-service/reference-candidate.js.map +1 -0
  107. package/lib/noop-logger.d.ts +25 -0
  108. package/lib/noop-logger.d.ts.map +1 -0
  109. package/lib/noop-logger.js +36 -0
  110. package/lib/noop-logger.js.map +1 -0
  111. package/lib/observable-value.d.ts +72 -0
  112. package/lib/observable-value.d.ts.map +1 -0
  113. package/lib/observable-value.js +52 -0
  114. package/lib/observable-value.js.map +1 -0
  115. package/lib/patch-merge.d.ts +123 -0
  116. package/lib/patch-merge.d.ts.map +1 -0
  117. package/lib/patch-merge.js +104 -0
  118. package/lib/patch-merge.js.map +1 -0
  119. package/lib/profile-session.d.ts +87 -0
  120. package/lib/profile-session.d.ts.map +1 -0
  121. package/lib/profile-session.js +98 -0
  122. package/lib/profile-session.js.map +1 -0
  123. package/lib/profiling.d.ts +83 -0
  124. package/lib/profiling.d.ts.map +1 -0
  125. package/lib/profiling.js +20 -0
  126. package/lib/profiling.js.map +1 -0
  127. package/lib/project.d.ts +121 -0
  128. package/lib/project.d.ts.map +1 -0
  129. package/lib/project.js +34 -0
  130. package/lib/project.js.map +1 -0
  131. package/lib/rpc/bind-rpc-methods.d.ts +100 -0
  132. package/lib/rpc/bind-rpc-methods.d.ts.map +1 -0
  133. package/lib/rpc/bind-rpc-methods.js +110 -0
  134. package/lib/rpc/bind-rpc-methods.js.map +1 -0
  135. package/lib/rpc/create-rpc-proxy.d.ts +188 -0
  136. package/lib/rpc/create-rpc-proxy.d.ts.map +1 -0
  137. package/lib/rpc/create-rpc-proxy.js +158 -0
  138. package/lib/rpc/create-rpc-proxy.js.map +1 -0
  139. package/lib/rpc/index.d.ts +12 -0
  140. package/lib/rpc/index.d.ts.map +1 -0
  141. package/lib/rpc/index.js +31 -0
  142. package/lib/rpc/index.js.map +1 -0
  143. package/lib/rpc/wire-prefix.d.ts +37 -0
  144. package/lib/rpc/wire-prefix.d.ts.map +1 -0
  145. package/lib/rpc/wire-prefix.js +49 -0
  146. package/lib/rpc/wire-prefix.js.map +1 -0
  147. package/lib/testing/data-doubles.d.ts +121 -0
  148. package/lib/testing/data-doubles.d.ts.map +1 -0
  149. package/lib/testing/data-doubles.js +102 -0
  150. package/lib/testing/data-doubles.js.map +1 -0
  151. package/lib/testing/fake-clock.d.ts +35 -0
  152. package/lib/testing/fake-clock.d.ts.map +1 -0
  153. package/lib/testing/fake-clock.js +86 -0
  154. package/lib/testing/fake-clock.js.map +1 -0
  155. package/lib/testing/harness.d.ts +24 -0
  156. package/lib/testing/harness.d.ts.map +1 -0
  157. package/lib/testing/harness.js +11 -0
  158. package/lib/testing/harness.js.map +1 -0
  159. package/lib/testing/index.d.ts +13 -0
  160. package/lib/testing/index.d.ts.map +1 -0
  161. package/lib/testing/index.js +41 -0
  162. package/lib/testing/index.js.map +1 -0
  163. package/lib/testing/node/duplex-connection.d.ts +25 -0
  164. package/lib/testing/node/duplex-connection.d.ts.map +1 -0
  165. package/lib/testing/node/duplex-connection.js +34 -0
  166. package/lib/testing/node/duplex-connection.js.map +1 -0
  167. package/lib/testing/node/duplex-stream.d.ts +32 -0
  168. package/lib/testing/node/duplex-stream.d.ts.map +1 -0
  169. package/lib/testing/node/duplex-stream.js +35 -0
  170. package/lib/testing/node/duplex-stream.js.map +1 -0
  171. package/lib/testing/node/index.d.ts +11 -0
  172. package/lib/testing/node/index.d.ts.map +1 -0
  173. package/lib/testing/node/index.js +37 -0
  174. package/lib/testing/node/index.js.map +1 -0
  175. package/lib/testing/wait-for.d.ts +69 -0
  176. package/lib/testing/wait-for.d.ts.map +1 -0
  177. package/lib/testing/wait-for.js +67 -0
  178. package/lib/testing/wait-for.js.map +1 -0
  179. package/lib/tracer.d.ts +115 -0
  180. package/lib/tracer.d.ts.map +1 -0
  181. package/lib/tracer.js +205 -0
  182. package/lib/tracer.js.map +1 -0
  183. package/lib/transfer-diagnostic.d.ts +73 -0
  184. package/lib/transfer-diagnostic.d.ts.map +1 -0
  185. package/lib/transfer-diagnostic.js +45 -0
  186. package/lib/transfer-diagnostic.js.map +1 -0
  187. package/lib/transfer-document.d.ts +73 -0
  188. package/lib/transfer-document.d.ts.map +1 -0
  189. package/lib/transfer-document.js +46 -0
  190. package/lib/transfer-document.js.map +1 -0
  191. package/lib/transfer-element.d.ts +39 -0
  192. package/lib/transfer-element.d.ts.map +1 -0
  193. package/lib/transfer-element.js +11 -0
  194. package/lib/transfer-element.js.map +1 -0
  195. package/lib/uri.d.ts +62 -0
  196. package/lib/uri.d.ts.map +1 -0
  197. package/lib/uri.js +28 -0
  198. package/lib/uri.js.map +1 -0
  199. package/lib/util.d.ts +147 -0
  200. package/lib/util.d.ts.map +1 -0
  201. package/lib/util.js +211 -0
  202. package/lib/util.js.map +1 -0
  203. package/package.json +100 -0
  204. package/src/abstract-logger.ts +120 -0
  205. package/src/browser-runtime.ts +135 -0
  206. package/src/client/data-events.ts +71 -0
  207. package/src/client/data-port.ts +99 -0
  208. package/src/client/data-session.ts +209 -0
  209. package/src/client/index.ts +40 -0
  210. package/src/client/message-relay.ts +216 -0
  211. package/src/client/post-message-transport.ts +167 -0
  212. package/src/clock.ts +173 -0
  213. package/src/data/data-protocol-methods.ts +79 -0
  214. package/src/data/data-server-protocol.ts +296 -0
  215. package/src/data/diagnostics.ts +116 -0
  216. package/src/data/events.ts +127 -0
  217. package/src/data/index.ts +23 -0
  218. package/src/data/methods.ts +59 -0
  219. package/src/data/requests.ts +70 -0
  220. package/src/debouncer.ts +118 -0
  221. package/src/errors.ts +127 -0
  222. package/src/host-diagnostics.ts +63 -0
  223. package/src/index.ts +41 -0
  224. package/src/latency-collector.ts +231 -0
  225. package/src/logger.ts +172 -0
  226. package/src/model-server.ts +479 -0
  227. package/src/model-service/args.ts +67 -0
  228. package/src/model-service/index.ts +16 -0
  229. package/src/model-service/reference-candidate.ts +58 -0
  230. package/src/noop-logger.ts +36 -0
  231. package/src/observable-value.ts +92 -0
  232. package/src/patch-merge.ts +165 -0
  233. package/src/profile-session.ts +164 -0
  234. package/src/profiling.ts +113 -0
  235. package/src/project.ts +125 -0
  236. package/src/rpc/README.md +163 -0
  237. package/src/rpc/bind-rpc-methods.ts +180 -0
  238. package/src/rpc/create-rpc-proxy.ts +292 -0
  239. package/src/rpc/index.ts +16 -0
  240. package/src/rpc/wire-prefix.ts +48 -0
  241. package/src/testing/data-doubles.ts +178 -0
  242. package/src/testing/fake-clock.ts +106 -0
  243. package/src/testing/harness.ts +24 -0
  244. package/src/testing/index.ts +26 -0
  245. package/src/testing/node/duplex-connection.ts +50 -0
  246. package/src/testing/node/duplex-stream.ts +51 -0
  247. package/src/testing/node/index.ts +22 -0
  248. package/src/testing/wait-for.ts +94 -0
  249. package/src/tracer.ts +277 -0
  250. package/src/transfer-diagnostic.ts +92 -0
  251. package/src/transfer-document.ts +99 -0
  252. package/src/transfer-element.ts +43 -0
  253. package/src/uri.ts +61 -0
  254. package/src/util.ts +250 -0
@@ -0,0 +1,92 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { type Disposable, type Event } from 'vscode-jsonrpc';
11
+
12
+ /**
13
+ * A value that may change over the connection lifetime. `value` is the
14
+ * current snapshot; `onChange` fires when the snapshot is replaced.
15
+ *
16
+ * The dynamic source in practice is always a user-configurable setting (see
17
+ * the `Settings` producers, which bind one to a Langium configuration
18
+ * section), but the type is source-agnostic — a constant wrapped via
19
+ * {@link ObservableValue.of} is still an `ObservableValue`; its `onChange`
20
+ * simply never fires. Recognised structurally by {@link isObservableValue}.
21
+ */
22
+ export interface ObservableValue<T> {
23
+ /**
24
+ * `readonly` means the holder cannot assign it, NOT that it is stable —
25
+ * a live cell backs this with a getter, so it must be read at the point of
26
+ * use. Copying it into a field at construction pins the snapshot and
27
+ * silently defeats the whole type. A setting-bound cell answers its declared
28
+ * default until the client's configuration fetch resolves, so an early read
29
+ * is a legitimate value rather than an error.
30
+ */
31
+ readonly value: T;
32
+ /**
33
+ * Fires when {@link value} is replaced, deduplicated by `Object.is`, so an
34
+ * unchanged push is not re-announced. Subscribing is OPTIONAL and usually
35
+ * unnecessary — reading `.value` per use already sees every change; the
36
+ * event is for consumers that must re-arm something a later read cannot fix
37
+ * (a timer already scheduled, a listener already registered).
38
+ *
39
+ * It never fires at all for a constant cell, so logic that lives only in an
40
+ * `onChange` handler does nothing when an adopter passes a plain value.
41
+ * Dispose what subscribing returns.
42
+ */
43
+ readonly onChange: Event<T>;
44
+ }
45
+
46
+ /**
47
+ * What a service option accepts: either a plain constant (one-shot, never
48
+ * changes) or an {@link ObservableValue}. Consumers normalise to a cell once
49
+ * via {@link ObservableValue.from} and read `.value` at the point of use.
50
+ */
51
+ export type MaybeObservableValue<T> = T | ObservableValue<T>;
52
+
53
+ const NOOP_DISPOSABLE: Disposable = Object.freeze({ dispose: () => undefined });
54
+
55
+ /** An {@link Event} that never fires — the `onChange` of a constant cell. */
56
+ const NEVER_EVENT: Event<never> = () => NOOP_DISPOSABLE;
57
+
58
+ /**
59
+ * Type-guard discriminating a {@link MaybeObservableValue} into its {@link ObservableValue}
60
+ * arm. Returns `false` for primitives, `null`, and any non-object payload; for
61
+ * objects, requires both a `value` property and a callable `onChange` accessor
62
+ * (vscode-jsonrpc events are exposed as callable subscribers).
63
+ */
64
+ export function isObservableValue<T>(input: MaybeObservableValue<T>): input is ObservableValue<T> {
65
+ if (input === null || typeof input !== 'object') {
66
+ return false;
67
+ }
68
+ const candidate = input as Partial<ObservableValue<T>>;
69
+ return 'value' in candidate && typeof candidate.onChange === 'function';
70
+ }
71
+
72
+ export namespace ObservableValue {
73
+ /**
74
+ * Wrap a constant as an {@link ObservableValue} whose `onChange` never
75
+ * fires. Adopters rarely need this — the constant arm of {@link MaybeObservableValue}
76
+ * already accepts a bare value; it exists for {@link from} and for tests
77
+ * that want an explicit cell.
78
+ */
79
+ export function of<T>(value: T): ObservableValue<T> {
80
+ return { value, onChange: NEVER_EVENT as Event<T> };
81
+ }
82
+
83
+ /**
84
+ * Normalise a {@link MaybeObservableValue} to an {@link ObservableValue}. Identity for a
85
+ * cell (preserving its live `value` getter + `onChange`); wraps a constant
86
+ * via {@link of}. The combinator a consumer calls once in its constructor
87
+ * so the rest of the class reads `.value` uniformly.
88
+ */
89
+ export function from<T>(input: MaybeObservableValue<T>): ObservableValue<T> {
90
+ return isObservableValue(input) ? input : of(input);
91
+ }
92
+ }
@@ -0,0 +1,165 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { applyPatch, compare, deepClone, getValueByPointer, type Operation as JsonPatchOperation } from 'fast-json-patch';
11
+
12
+ /**
13
+ * Augment a user-intent JSON patch (computed `baseline → attempted`) with
14
+ * `test` ops so a strict `applyPatch` against freshly-fetched server state
15
+ * fails loudly when a foreign writer changed a path the user also changed.
16
+ *
17
+ * `fast-json-patch`'s `validateOperation: true` validates op structure and
18
+ * path resolvability but NOT the pre-existing value — a plain `replace` /
19
+ * `remove` silently overwrites whatever the foreign writer put there. For each
20
+ * `replace` / `remove` op we prepend a `test` op carrying the baseline value at
21
+ * that path, so a same-path divergence becomes a `TEST_OPERATION_FAILED` throw.
22
+ * The caller treats that as a real field-level conflict (drop + refetch) rather
23
+ * than silently clobbering the foreign edit.
24
+ *
25
+ * `add` ops are left unguarded: the path is new, so there is no baseline value
26
+ * to test against, and add-vs-add overlaps are out of scope for this floor.
27
+ *
28
+ * Used behind the framework's `ConflictError` contract by every reconcile
29
+ * path — the GLSP recording command's undo/redo and forward-write, via
30
+ * {@link ReconcilingConflictResolver}, and an adopter's form-widget save — so
31
+ * they share one collision-detection rule.
32
+ */
33
+ export function augmentWithTestOps(baseline: object, userPatch: ReadonlyArray<JsonPatchOperation>): JsonPatchOperation[] {
34
+ const augmented: JsonPatchOperation[] = [];
35
+ for (const op of userPatch) {
36
+ if (op.op === 'replace' || op.op === 'remove') {
37
+ augmented.push({ op: 'test', path: op.path, value: getValueByPointer(baseline, op.path) });
38
+ }
39
+ augmented.push(op);
40
+ }
41
+ return augmented;
42
+ }
43
+
44
+ /**
45
+ * Outcome of {@link reconcileByPatchReplay}. The caller persists / re-baselines
46
+ * on `merged`, drops + surfaces the `fresh` root on `conflict`, and decides its
47
+ * own fallback (e.g. force-retry) on `no-op` / `unavailable`.
48
+ */
49
+ export type ReconcileOutcome<T> =
50
+ | {
51
+ /** The replay succeeded: the foreign writer touched no path the user did. */
52
+ status: 'merged';
53
+ /**
54
+ * A fresh root built on the refetched server state, carrying both
55
+ * intents. It is NOT the caller's `attempted` root — re-baseline on
56
+ * this value, or the next write diffs against state the server never
57
+ * had.
58
+ */
59
+ merged: T;
60
+ }
61
+ | {
62
+ /**
63
+ * The user's root already equalled the baseline, so the version gate
64
+ * fired on drift that changed nothing. No refetch was performed and
65
+ * there is nothing to persist — retrying the same write reproduces it.
66
+ */
67
+ status: 'no-op';
68
+ }
69
+ | {
70
+ /** A guarded `test` op tripped: user and foreign writer touched one path. */
71
+ status: 'conflict';
72
+ /**
73
+ * The server's current root, refetched and unmodified — the user's
74
+ * intent was NOT applied to it. Surface it and drop the write; treating
75
+ * it as a merge result silently discards what the user typed.
76
+ */
77
+ fresh: T;
78
+ }
79
+ | {
80
+ /**
81
+ * The refetch produced nothing, so reconciliation could not be
82
+ * attempted at all. This says nothing about whether a conflict exists;
83
+ * the caller picks its own fallback (force-write, retry, surface).
84
+ */
85
+ status: 'unavailable';
86
+ };
87
+
88
+ /**
89
+ * Shared three-way reconcile for a `ConflictError`: diff the user's intent
90
+ * (`baseline → attempted`), refetch the server's current root, and replay the
91
+ * intent on top under strict, {@link augmentWithTestOps}-guarded `applyPatch`.
92
+ *
93
+ * - `no-op` — the user's root equals the baseline, so the gate fired on a
94
+ * benign version drift; nothing to replay (refetch is skipped).
95
+ * - `unavailable` — the refetch produced nothing; caller falls back.
96
+ * - `merged` — the foreign writer touched only paths the user did not; the
97
+ * merged root carries both intents.
98
+ * - `conflict` — a same-path divergence tripped a `test` op; caller drops the
99
+ * write and surfaces `fresh`.
100
+ *
101
+ * I/O is the caller's: `refetch` supplies the current root, and applying the
102
+ * `merged` result (update vs save, re-baseline, UI refresh) stays at the call
103
+ * site so form and GLSP paths keep their own persistence semantics.
104
+ */
105
+ export async function reconcileByPatchReplay<T extends object>(
106
+ baseline: T,
107
+ attempted: T,
108
+ refetch: () => Promise<T | undefined>
109
+ ): Promise<ReconcileOutcome<T>> {
110
+ const userPatch = compare(baseline, attempted);
111
+ if (userPatch.length === 0) {
112
+ return { status: 'no-op' };
113
+ }
114
+ const fresh = await refetch();
115
+ if (!fresh) {
116
+ return { status: 'unavailable' };
117
+ }
118
+ try {
119
+ const merged = applyPatch(deepClone(fresh), augmentWithTestOps(baseline, userPatch), true).newDocument;
120
+ return { status: 'merged', merged };
121
+ } catch {
122
+ return { status: 'conflict', fresh };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Strategy for resolving a write that raced a foreign edit. One declarative
128
+ * choice covers every conflict site — `execute` forward-write `(before,
129
+ * after)`, `undo` `(after, before)`, `redo` `(before, after)`, and form / GLSP
130
+ * save `(lastSynced, newRoot)` — so adopters pick reconcile-vs-force once
131
+ * (by binding a resolver) rather than per call site.
132
+ */
133
+ export interface ConflictResolver {
134
+ /**
135
+ * Reconcile the user's `baseline → attempted` intent against the current
136
+ * server state (`refetch`), returning a {@link ReconcileOutcome} the caller
137
+ * acts on (persist `merged`, drop on `conflict`, fall back on `no-op` /
138
+ * `unavailable`).
139
+ */
140
+ resolve<T extends object>(baseline: T, attempted: T, refetch: () => Promise<T | undefined>): Promise<ReconcileOutcome<T>>;
141
+ }
142
+
143
+ /**
144
+ * Default {@link ConflictResolver}: field-level three-way merge via
145
+ * {@link reconcileByPatchReplay}. A foreign edit to a different field is
146
+ * merged; a same-field collision is reported as a conflict rather than
147
+ * clobbered.
148
+ */
149
+ export class ReconcilingConflictResolver implements ConflictResolver {
150
+ resolve<T extends object>(baseline: T, attempted: T, refetch: () => Promise<T | undefined>): Promise<ReconcileOutcome<T>> {
151
+ return reconcileByPatchReplay(baseline, attempted, refetch);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Last-writer-wins {@link ConflictResolver}: always reports the `attempted`
157
+ * state as merged, without refetching or guarding. Suitable for single-client
158
+ * tools or always-regenerated artifacts where a concurrent foreign edit may be
159
+ * overwritten. A foreign edit to any field is clobbered.
160
+ */
161
+ export class ForceConflictResolver implements ConflictResolver {
162
+ async resolve<T extends object>(_baseline: T, attempted: T, _refetch: () => Promise<T | undefined>): Promise<ReconcileOutcome<T>> {
163
+ return { status: 'merged', merged: attempted };
164
+ }
165
+ }
@@ -0,0 +1,164 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { type Clock, type Stopwatch } from './clock';
11
+ import { Format, Logger, type LogThreshold } from './logger';
12
+
13
+ /** One aggregated row of a {@link ProfileSession}: a scope id and its totals. */
14
+ export interface ProfileRecord {
15
+ /** The scope id passed to {@link ProfileSession.scope}. */
16
+ id: string;
17
+ /** How many times a scope with this id ran. */
18
+ count: number;
19
+ /** Summed self-time across those runs (wall-clock minus nested scopes). */
20
+ selfMs: number;
21
+ /** Self-time as a percentage of the session's total wall-clock. */
22
+ selfPct: number;
23
+ }
24
+
25
+ /**
26
+ * An aggregating, parent-exclusive self-time profiler scoped to one operation.
27
+ *
28
+ * Unlike `Tracer.time`, which emits one log line per
29
+ * call and keeps the timeline, a {@link ProfileSession} collapses many
30
+ * {@link scope} calls into a compact per-id aggregate (count + self-time +
31
+ * self-%), then dumps it as line-based output through a {@link Logger}. It is
32
+ * the in-house analogue of Langium's `ProfilingTask` (per-rule / per-node-type
33
+ * self-time) but for the framework's own passes — integrity rules, GModel
34
+ * construction, the save/reconcile chain — that Langium's profiler does not
35
+ * cover.
36
+ *
37
+ * Spawned per operation via `tracer.profile(id)`, used, reported, discarded —
38
+ * it is stateful and op-scoped, which is why it is not a method on the
39
+ * long-lived {@link Logger}/`Tracer`.
40
+ */
41
+ export interface ProfileSession {
42
+ /**
43
+ * Run `fn` as a measured task `id`, accumulating its self-time = wall-clock
44
+ * minus any nested {@link scope} calls. Re-entrant: an inner scope nests and
45
+ * its full duration is excluded from the enclosing id's self-time
46
+ * (parent-exclusive). Async `fn` is awaited so timing covers the full
47
+ * settle. Returns `fn`'s result unchanged; rethrows after recording the
48
+ * elapsed time of a throwing scope.
49
+ */
50
+ scope<T>(id: string, fn: () => Promise<T>): Promise<T>;
51
+ scope<T>(id: string, fn: () => T): T;
52
+
53
+ /**
54
+ * Emit the aggregate through the bound {@link Logger}: one line per id,
55
+ * sorted by self-time descending, plus an "unaccounted" remainder (the
56
+ * session wall-clock not attributed to any top-level scope). No-op when
57
+ * `logLevel` is suppressed by the global threshold (incl. `'off'`).
58
+ */
59
+ report(logLevel?: LogThreshold): void;
60
+
61
+ /** The same aggregate as structured records, for tests / programmatic use. */
62
+ records(): readonly ProfileRecord[];
63
+ }
64
+
65
+ interface ScopeFrame {
66
+ readonly id: string;
67
+ /** Session-stopwatch reading when this scope began. */
68
+ readonly start: number;
69
+ /** Wall-time consumed by nested scopes, excluded from this scope's self-time. */
70
+ childMs: number;
71
+ }
72
+
73
+ /**
74
+ * Default {@link ProfileSession}. Times against an injected {@link Clock} (so
75
+ * it is fake-clock deterministic in tests, unlike a raw `performance.now`) and
76
+ * dumps through an injected {@link Logger}.
77
+ */
78
+ export class DefaultProfileSession implements ProfileSession {
79
+ /** Single monotonic timeline for the whole session; per-scope readings are deltas off it. */
80
+ protected readonly sessionStopwatch: Stopwatch;
81
+ protected readonly stack: ScopeFrame[] = [];
82
+ protected readonly entries = new Map<string, number[]>();
83
+
84
+ constructor(
85
+ protected readonly logger: Logger,
86
+ clock: Clock,
87
+ protected readonly identifier: string
88
+ ) {
89
+ this.sessionStopwatch = clock.stopwatch();
90
+ }
91
+
92
+ scope<T>(id: string, fn: () => Promise<T>): Promise<T>;
93
+ scope<T>(id: string, fn: () => T): T;
94
+ scope<T>(id: string, fn: () => T | Promise<T>): T | Promise<T> {
95
+ const frame: ScopeFrame = { id, start: this.sessionStopwatch.elapsedMs, childMs: 0 };
96
+ this.stack.push(frame);
97
+ let result: T | Promise<T>;
98
+ try {
99
+ result = fn();
100
+ } catch (error) {
101
+ this.closeFrame(frame);
102
+ throw error;
103
+ }
104
+ if (result instanceof Promise) {
105
+ return result.then(
106
+ value => {
107
+ this.closeFrame(frame);
108
+ return value;
109
+ },
110
+ error => {
111
+ this.closeFrame(frame);
112
+ throw error;
113
+ }
114
+ );
115
+ }
116
+ this.closeFrame(frame);
117
+ return result;
118
+ }
119
+
120
+ /** Pop `frame`, charge its full duration to the parent, and record its self-time. */
121
+ protected closeFrame(frame: ScopeFrame): void {
122
+ this.stack.pop();
123
+ const duration = this.sessionStopwatch.elapsedMs - frame.start;
124
+ const parent = this.stack[this.stack.length - 1];
125
+ if (parent) {
126
+ parent.childMs += duration;
127
+ }
128
+ const selfMs = duration - frame.childMs;
129
+ const samples = this.entries.get(frame.id);
130
+ if (samples) {
131
+ samples.push(selfMs);
132
+ } else {
133
+ this.entries.set(frame.id, [selfMs]);
134
+ }
135
+ }
136
+
137
+ records(): readonly ProfileRecord[] {
138
+ const totalMs = this.sessionStopwatch.elapsedMs;
139
+ const records: ProfileRecord[] = [];
140
+ for (const [id, samples] of this.entries) {
141
+ const selfMs = samples.reduce((sum, value) => sum + value, 0);
142
+ records.push({ id, count: samples.length, selfMs, selfPct: totalMs > 0 ? (100 * selfMs) / totalMs : 0 });
143
+ }
144
+ return records.sort((left, right) => right.selfMs - left.selfMs);
145
+ }
146
+
147
+ report(logLevel: LogThreshold = 'debug'): void {
148
+ if (logLevel === 'off' || !Logger.isLevelEnabled(logLevel)) {
149
+ return;
150
+ }
151
+ const totalMs = this.sessionStopwatch.elapsedMs;
152
+ const records = this.records();
153
+ const emit = this.logger[logLevel].bind(this.logger);
154
+ for (const record of records) {
155
+ emit(`[profile ${this.identifier}] ${record.id} ×${record.count} ${Math.round(record.selfPct)}% ${Format.elapsed(record.selfMs)}`);
156
+ }
157
+ const accountedMs = records.reduce((sum, record) => sum + record.selfMs, 0);
158
+ const unaccountedMs = totalMs - accountedMs;
159
+ if (unaccountedMs >= 0.5) {
160
+ const pct = totalMs > 0 ? Math.round((100 * unaccountedMs) / totalMs) : 0;
161
+ emit(`[profile ${this.identifier}] (unaccounted) ${pct}% ${Format.elapsed(unaccountedMs)}`);
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,113 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ /*
11
+ * Neutral shape of a sampled-profile capture request. It lives in `protocol`
12
+ * (not the Node-only `@hydranium/core/node` capture implementation) because it
13
+ * crosses the wire — the data-server diagnostics `startProfiling` args extend it,
14
+ * and a frontend/CLI names the dimensions to capture. The implementation
15
+ * (`ProfileCapture`, the `node:inspector` session) stays in `@hydranium/core/node`.
16
+ */
17
+
18
+ /** Which dimensions to capture, and the two sampling knobs. All off by default. */
19
+ export interface ProfileCaptureOptions {
20
+ cpu?: boolean;
21
+ allocation?: boolean;
22
+ gc?: boolean;
23
+ eventLoopDelay?: boolean;
24
+ /** Also drop a retained `.heapsnapshot` at stop (for the memory analyzer). */
25
+ heapSnapshot?: boolean;
26
+ /** CPU sampling interval in µs (implementation default 1000). */
27
+ cpuIntervalMicros?: number;
28
+ /** Allocation sampling interval in bytes (implementation default 32768). */
29
+ allocationIntervalBytes?: number;
30
+ }
31
+
32
+ /*
33
+ * The manifest schema — the self-describing index over one profiling run's
34
+ * artefacts. It lives in `protocol` (not the Node-only `ProfilingRun` that writes
35
+ * it) so both the server-side assembler AND a Playwright-side assembler can share
36
+ * the shape without a `node`-only dependency chain. `ProfilingRun` re-exports
37
+ * these from `@hydranium/core/node`.
38
+ */
39
+
40
+ export const PROFILING_SCHEMA_VERSION = 1;
41
+
42
+ /**
43
+ * The locked, origin-first artefact kinds (the kind equals the artefact's filename stem).
44
+ * A closed union so a producer cannot emit a stale or typo'd kind and still
45
+ * compile — the drift the naming scheme forbids.
46
+ */
47
+ export type ProfilingArtifactKind =
48
+ | 'server-cpu'
49
+ | 'server-alloc'
50
+ | 'server-heap'
51
+ | 'server-summary'
52
+ | 'server-latency'
53
+ | 'server-ast'
54
+ | 'server-memory-report'
55
+ | 'server-log'
56
+ | 'browser-runtime'
57
+ | 'browser-timeline'
58
+ | 'browser-heap'
59
+ | 'browser-console';
60
+
61
+ /** One entry in the manifest — a captured file plus enough context for a skill to know what it is. */
62
+ export interface ProfilingArtifact {
63
+ /** The artefact's origin-first kind; equals its filename stem. */
64
+ kind: ProfilingArtifactKind;
65
+ /** Path relative to the manifest (the artefact lives beside it in the session directory). */
66
+ path: string;
67
+ /** The window the artefact was captured in, when it is window-scoped. */
68
+ window?: string;
69
+ /** Pointer to a compact digest file for a heavy artefact (added by the digest emitters). */
70
+ digest?: string;
71
+ }
72
+
73
+ /** What a skill reads to know what to expect from a run (browser entries only in `app` mode, etc.). */
74
+ export interface ProfilingEnvironment {
75
+ mode: 'harness' | 'app';
76
+ /** Whether a cgroup memory controller is present (running in a pod) vs local. */
77
+ container: boolean;
78
+ commit?: string;
79
+ workspace?: string;
80
+ node: string;
81
+ windows: { label: string; ms: number }[];
82
+ }
83
+
84
+ /** The `manifest.json` contents — the index over one run's artefacts. */
85
+ export interface ProfilingManifest {
86
+ /**
87
+ * Always {@link PROFILING_SCHEMA_VERSION} as written; declared as `number`
88
+ * because a READER may be newer than the run it is opening. Check it before
89
+ * trusting any field — an older manifest can be missing fields this
90
+ * declaration says are required.
91
+ */
92
+ schemaVersion: number;
93
+ /**
94
+ * Names the session directory the manifest sits in, so it identifies a run
95
+ * without carrying an absolute path. Unique per run by construction (a
96
+ * timestamp when the caller supplies none), but nothing enforces that a
97
+ * caller-supplied id is not reused.
98
+ */
99
+ sessionId: string;
100
+ /**
101
+ * What the run was captured against — needed to know which artefacts to
102
+ * expect at all, since the browser kinds only occur in `app` mode, and to
103
+ * know whether two runs are comparable.
104
+ */
105
+ environment: ProfilingEnvironment;
106
+ /**
107
+ * Every file written beside the manifest. Presence is the only signal that a
108
+ * dimension was captured: an artefact for a dimension that was not requested
109
+ * is absent rather than empty, and a kind may occur more than once when the
110
+ * run has several windows.
111
+ */
112
+ artifacts: ProfilingArtifact[];
113
+ }
package/src/project.ts ADDED
@@ -0,0 +1,125 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ /**
11
+ * Cross-cutting domain type for the project tier — a descriptor-discovered,
12
+ * optionally versioned and dependency-aware sub-grouping of model files
13
+ * inside an LSP workspace folder.
14
+ *
15
+ * Owned by `@hydranium/protocol` (rather than `@hydranium/core`) so
16
+ * protocol consumers — including browser-bundle frontend code that
17
+ * imports the typed data-server proxy — pull this type without
18
+ * transitively depending on Langium. The `Project` shape is the structural
19
+ * baseline both the framework's runtime project tier (`ProjectManager` in
20
+ * `@hydranium/core`) and its wire-level surface (`DataServerProtocol`
21
+ * methods returning projects) agree on; collapsing them into one
22
+ * type eliminates a duplicate definition that would otherwise drift.
23
+ *
24
+ * **Project tier rationale.** Sits at a tier Langium itself deliberately
25
+ * does not provide. Langium's `WorkspaceManager` indexes all files in all
26
+ * workspace folders into one flat global scope; the maintainers have
27
+ * on-record stated they intend to keep it that way — see
28
+ * [Discussion #1375 — *Multiple Languages from different Projects*](https://github.com/eclipse-langium/langium/discussions/1375).
29
+ * This framework fills that gap so consumers don't have to reinvent it.
30
+ * Consumers that don't need projects bind `SingleProjectManager` and pay
31
+ * no cost — one synthetic project at runtime, no descriptor tracking,
32
+ * no events.
33
+ *
34
+ * **Extension model.** Adopters with richer per-project metadata declare a
35
+ * subtype `interface MyProject extends Project { ... }` and parameterise
36
+ * `ProjectManager<MyProject>` / `DataServer<TRoot, TDiagnostic, MyProject>`.
37
+ * The framework's structural read from a project is `id` (registry key,
38
+ * dependency-graph identity) plus optionally `dependencies` (visibility-
39
+ * closure walk); every other field is either wire metadata (`version`) or
40
+ * adopter-specific extension carried transparently through the typed
41
+ * surface. The framework imposes no format on extension fields — they
42
+ * ride on the JSON-RPC envelope as additional properties when crossing
43
+ * the wire.
44
+ */
45
+ export interface Project {
46
+ /**
47
+ * Stable, unique identifier. Used as the lookup key in
48
+ * `ProjectManager.getProjectById` and as the value in
49
+ * {@link dependencies} entries that point at this project.
50
+ * Conventionally a name-plus-version string (e.g.
51
+ * `"example-dwh@1.0.0"`), but the framework imposes no format — any
52
+ * non-empty string works.
53
+ *
54
+ * The framework's internal identifier — map keys, visibility-set
55
+ * membership, dependency-declaration matching. Distinct from the
56
+ * user-facing {@link referenceName}; see there for why they are separate.
57
+ */
58
+ readonly id: string;
59
+
60
+ /**
61
+ * User-facing prefix used in qualified-name references that cross
62
+ * project boundaries. Composed by `NameProvider.getProjectQualifiedName`
63
+ * to produce the workspace-unique form that the linker resolves
64
+ * cross-project references against; emitted as the name of the
65
+ * `TieredAstNodeDescription` produced by
66
+ * `HydraniumScopeComputation.addExportedSymbol` when the project-
67
+ * qualified form differs from the document-qualified form.
68
+ *
69
+ * Set to {@link UNQUALIFIED_PROJECT_REFERENCE} (`''`) to contribute no
70
+ * prefix at all — see that constant for the semantics.
71
+ *
72
+ * **Why this is a separate field from {@link id}.** Two reasons:
73
+ * 1. Versioned ids (e.g. `"example-dwh@1.0.0"`) need un-versioned
74
+ * names in reference syntax (`example-dwh.User.email`) — the
75
+ * version pinning only matters for dependency declarations.
76
+ * 2. Names with grammar-illegal characters (dots, spaces) need
77
+ * sanitisation. The framework cannot safely sanitise {@link id}
78
+ * (it would break dependency matching); a separate field lets
79
+ * adopters sanitise once at parse time.
80
+ */
81
+ readonly referenceName: string;
82
+
83
+ /**
84
+ * Optional semantic version of the project. Distinct from {@link id}
85
+ * because two versions of the same logical project are different
86
+ * projects (different {@link id}s) — `version` is metadata for tooling /
87
+ * display / version-matched dependency declarations, not part of
88
+ * identity. The framework does not read this field internally; it
89
+ * passes through to clients as-is.
90
+ */
91
+ readonly version?: string;
92
+
93
+ /**
94
+ * Ids of other projects this project explicitly depends on. Concrete
95
+ * `ProjectManager` implementations decide what "depends on" means
96
+ * and combine these into the visibility closure via
97
+ * `ProjectManager.getVisibleProjects` — the framework default in
98
+ * `AbstractProjectManager` walks them transitively; adopters can
99
+ * override.
100
+ */
101
+ readonly dependencies?: readonly string[];
102
+ }
103
+
104
+ /**
105
+ * Sentinel value for {@link Project.referenceName} indicating the
106
+ * project does not prefix its names with a reference segment. Names
107
+ * from such projects contribute to the workspace-wide namespace as-is —
108
+ * `NameProvider.getProjectQualifiedName` collapses to
109
+ * `getDocumentQualifiedName`, and `HydraniumScopeComputation` emits a
110
+ * single description per node (no separate `tier: 'public'` entry,
111
+ * since it would carry the same name).
112
+ *
113
+ * Typical use:
114
+ * - `SingleProjectManager` workspaces (no inter-project disambiguation
115
+ * needed — there is only one project, the synthetic workspace project).
116
+ * - Adopters whose grammar has no qualified-name reference syntax (e.g.
117
+ * class references written as bare `[Class:ID]` tokens at the framework
118
+ * boundary; per-package projects all bind this sentinel).
119
+ *
120
+ * The empty string is chosen so that `referenceName + nameSeparator + name`
121
+ * compositions detect the unqualified case without an explicit null
122
+ * check on the consumer side — but for predicate readability prefer
123
+ * `ProjectManager.isUnqualifiedProjectReference(uri)`.
124
+ */
125
+ export const UNQUALIFIED_PROJECT_REFERENCE = '';