@elaraai/e3-types 1.0.79 → 1.0.81

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 (158) hide show
  1. package/README.md +1 -1
  2. package/dist/src/api.d.ts +829 -36
  3. package/dist/src/api.d.ts.map +1 -1
  4. package/dist/src/api.js +301 -32
  5. package/dist/src/api.js.map +1 -1
  6. package/dist/src/collection-manifest.d.ts +37 -0
  7. package/dist/src/collection-manifest.d.ts.map +1 -0
  8. package/dist/src/collection-manifest.js +37 -0
  9. package/dist/src/collection-manifest.js.map +1 -0
  10. package/dist/src/dataflow.d.ts +264 -19
  11. package/dist/src/dataflow.d.ts.map +1 -1
  12. package/dist/src/dataflow.js +156 -15
  13. package/dist/src/dataflow.js.map +1 -1
  14. package/dist/src/dataflow.spec.js +184 -27
  15. package/dist/src/dataflow.spec.js.map +1 -1
  16. package/dist/src/dataset-blob.d.ts +75 -15
  17. package/dist/src/dataset-blob.d.ts.map +1 -1
  18. package/dist/src/dataset-blob.js +95 -28
  19. package/dist/src/dataset-blob.js.map +1 -1
  20. package/dist/src/dataset-blob.spec.d.ts +6 -0
  21. package/dist/src/dataset-blob.spec.d.ts.map +1 -0
  22. package/dist/src/dataset-blob.spec.js +92 -0
  23. package/dist/src/dataset-blob.spec.js.map +1 -0
  24. package/dist/src/dataset-ref.d.ts +5 -4
  25. package/dist/src/dataset-ref.d.ts.map +1 -1
  26. package/dist/src/dataset-ref.js +5 -4
  27. package/dist/src/dataset-ref.js.map +1 -1
  28. package/dist/src/environment.d.ts +3 -2
  29. package/dist/src/environment.d.ts.map +1 -1
  30. package/dist/src/environment.js +3 -2
  31. package/dist/src/environment.js.map +1 -1
  32. package/dist/src/execution.d.ts +112 -18
  33. package/dist/src/execution.d.ts.map +1 -1
  34. package/dist/src/execution.js +174 -45
  35. package/dist/src/execution.js.map +1 -1
  36. package/dist/src/execution.spec.d.ts +6 -0
  37. package/dist/src/execution.spec.d.ts.map +1 -0
  38. package/dist/src/execution.spec.js +108 -0
  39. package/dist/src/execution.spec.js.map +1 -0
  40. package/dist/src/function.d.ts +14 -12
  41. package/dist/src/function.d.ts.map +1 -1
  42. package/dist/src/function.js +17 -32
  43. package/dist/src/function.js.map +1 -1
  44. package/dist/src/index.d.ts +17 -12
  45. package/dist/src/index.d.ts.map +1 -1
  46. package/dist/src/index.js +28 -20
  47. package/dist/src/index.js.map +1 -1
  48. package/dist/src/intake.d.ts +75 -0
  49. package/dist/src/intake.d.ts.map +1 -0
  50. package/dist/src/intake.js +46 -0
  51. package/dist/src/intake.js.map +1 -0
  52. package/dist/src/lock.d.ts +253 -21
  53. package/dist/src/lock.d.ts.map +1 -1
  54. package/dist/src/lock.js +55 -21
  55. package/dist/src/lock.js.map +1 -1
  56. package/dist/src/names.d.ts +21 -0
  57. package/dist/src/names.d.ts.map +1 -0
  58. package/dist/src/names.js +31 -0
  59. package/dist/src/names.js.map +1 -0
  60. package/dist/src/package.d.ts +11 -78
  61. package/dist/src/package.d.ts.map +1 -1
  62. package/dist/src/package.js +13 -66
  63. package/dist/src/package.js.map +1 -1
  64. package/dist/src/record.d.ts +582 -18
  65. package/dist/src/record.d.ts.map +1 -1
  66. package/dist/src/record.js +405 -7
  67. package/dist/src/record.js.map +1 -1
  68. package/dist/src/{stream.spec.d.ts → record.spec.d.ts} +1 -1
  69. package/dist/src/record.spec.d.ts.map +1 -0
  70. package/dist/src/record.spec.js +143 -0
  71. package/dist/src/record.spec.js.map +1 -0
  72. package/dist/src/release.d.ts +36 -0
  73. package/dist/src/release.d.ts.map +1 -0
  74. package/dist/src/release.js +71 -0
  75. package/dist/src/release.js.map +1 -0
  76. package/dist/src/release.spec.d.ts +6 -0
  77. package/dist/src/release.spec.d.ts.map +1 -0
  78. package/dist/src/release.spec.js +42 -0
  79. package/dist/src/release.spec.js.map +1 -0
  80. package/dist/src/repository.d.ts +64 -0
  81. package/dist/src/repository.d.ts.map +1 -0
  82. package/dist/src/repository.js +49 -0
  83. package/dist/src/repository.js.map +1 -0
  84. package/dist/src/runner.d.ts +16 -62
  85. package/dist/src/runner.d.ts.map +1 -1
  86. package/dist/src/runner.js +16 -79
  87. package/dist/src/runner.js.map +1 -1
  88. package/dist/src/runner.spec.js +11 -85
  89. package/dist/src/runner.spec.js.map +1 -1
  90. package/dist/src/structure.d.ts +0 -57
  91. package/dist/src/structure.d.ts.map +1 -1
  92. package/dist/src/structure.js +0 -3
  93. package/dist/src/structure.js.map +1 -1
  94. package/dist/src/task.d.ts +210 -318
  95. package/dist/src/task.d.ts.map +1 -1
  96. package/dist/src/task.js +117 -472
  97. package/dist/src/task.js.map +1 -1
  98. package/dist/src/task.spec.js +62 -154
  99. package/dist/src/task.spec.js.map +1 -1
  100. package/dist/src/transfer.d.ts +38 -26
  101. package/dist/src/transfer.d.ts.map +1 -1
  102. package/dist/src/transfer.js +22 -26
  103. package/dist/src/transfer.js.map +1 -1
  104. package/dist/src/transfer.spec.js +4 -34
  105. package/dist/src/transfer.spec.js.map +1 -1
  106. package/dist/src/unit-plan.d.ts +130 -0
  107. package/dist/src/unit-plan.d.ts.map +1 -0
  108. package/dist/src/unit-plan.js +107 -0
  109. package/dist/src/unit-plan.js.map +1 -0
  110. package/dist/src/unit-plan.spec.d.ts +6 -0
  111. package/dist/src/unit-plan.spec.d.ts.map +1 -0
  112. package/dist/src/unit-plan.spec.js +29 -0
  113. package/dist/src/unit-plan.spec.js.map +1 -0
  114. package/dist/src/workspace.d.ts +22 -11
  115. package/dist/src/workspace.d.ts.map +1 -1
  116. package/dist/src/workspace.js +10 -11
  117. package/dist/src/workspace.js.map +1 -1
  118. package/package.json +3 -3
  119. package/src/api.ts +326 -32
  120. package/src/collection-manifest.ts +53 -0
  121. package/src/dataflow.spec.ts +223 -31
  122. package/src/dataflow.ts +183 -26
  123. package/src/dataset-blob.spec.ts +117 -0
  124. package/src/dataset-blob.ts +157 -21
  125. package/src/dataset-ref.ts +5 -4
  126. package/src/environment.ts +3 -2
  127. package/src/execution.spec.ts +114 -0
  128. package/src/execution.ts +175 -51
  129. package/src/function.ts +19 -32
  130. package/src/index.ts +141 -39
  131. package/src/intake.ts +53 -0
  132. package/src/lock.ts +67 -21
  133. package/src/names.ts +39 -0
  134. package/src/package.ts +15 -71
  135. package/src/record.spec.ts +195 -0
  136. package/src/record.ts +489 -7
  137. package/src/release.spec.ts +47 -0
  138. package/src/release.ts +69 -0
  139. package/src/repository.ts +67 -0
  140. package/src/runner.spec.ts +11 -94
  141. package/src/runner.ts +16 -80
  142. package/src/structure.ts +0 -8
  143. package/src/task.spec.ts +67 -188
  144. package/src/task.ts +140 -522
  145. package/src/transfer.spec.ts +4 -51
  146. package/src/transfer.ts +22 -26
  147. package/src/unit-plan.spec.ts +31 -0
  148. package/src/unit-plan.ts +121 -0
  149. package/src/workspace.ts +13 -11
  150. package/dist/src/stream.d.ts +0 -97
  151. package/dist/src/stream.d.ts.map +0 -1
  152. package/dist/src/stream.js +0 -134
  153. package/dist/src/stream.js.map +0 -1
  154. package/dist/src/stream.spec.d.ts.map +0 -1
  155. package/dist/src/stream.spec.js +0 -60
  156. package/dist/src/stream.spec.js.map +0 -1
  157. package/src/stream.spec.ts +0 -88
  158. package/src/stream.ts +0 -170
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Copyright (c) 2025 Elara AI Pty Ltd
3
+ * Dual-licensed under AGPL-3.0 and commercial license. See LICENSE for details.
4
+ */
5
+
6
+ /**
7
+ * The encoder door: a collection is written as the manifest the Writer's own
8
+ * manifest writer writes for its value — which the conformance corpus pins in
9
+ * every runtime — whichever pieces it arrives in.
10
+ */
11
+
12
+ import { describe, it } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import {
15
+ Beast2ManifestWriter,
16
+ DictType,
17
+ IntegerType,
18
+ SortedMap,
19
+ StringType,
20
+ StructType,
21
+ compareFor,
22
+ decodeBeast2FenceFor,
23
+ decodeCollectionManifest,
24
+ encodeBeast2For,
25
+ encodeBeast2PagedFor,
26
+ sha256Hex,
27
+ } from '@elaraai/east';
28
+ import { encodeDatasetBlob, writeCollectionManifest, type CollectionSegmentRef } from './dataset-blob.js';
29
+
30
+ const TableType = DictType(StringType, IntegerType);
31
+
32
+ describe('writeCollectionManifest', () => {
33
+ it("writes the manifest writer's manifest for the value, byte for byte", async () => {
34
+ const value = new SortedMap(
35
+ Array.from({ length: 20_000 }, (_, i): [string, bigint] => [`k${String(i).padStart(6, '0')}`, BigInt(i)]),
36
+ compareFor(StringType));
37
+ const objects = new Map<string, Uint8Array>();
38
+ const manifest = await writeCollectionManifest(TableType, [{ elements: value.entries() }], async (bytes) => {
39
+ const hash = sha256Hex(bytes);
40
+ objects.set(hash, bytes);
41
+ return hash;
42
+ });
43
+
44
+ const expectedObjects = new Map<string, Uint8Array>();
45
+ let expected: Uint8Array | undefined;
46
+ const writer = new Beast2ManifestWriter(TableType, {
47
+ object: (hash, bytes) => { expectedObjects.set(hash, bytes); },
48
+ manifest: (bytes) => { expected = bytes; },
49
+ });
50
+ for (const element of value) writer.add(element);
51
+ writer.finish();
52
+
53
+ assert.deepEqual(manifest, expected);
54
+ assert.deepEqual([...objects.keys()].sort(), [...expectedObjects.keys()].sort(), 'the same objects, header included');
55
+ assert.ok(decodeCollectionManifest(manifest).entries.length > 1, 'the value spans segments');
56
+ });
57
+
58
+ it('names the segments a piece carries with their entries again, neither reading nor writing them', async () => {
59
+ const value = new SortedMap(
60
+ Array.from({ length: 20_000 }, (_, i): [string, bigint] => [`k${String(i).padStart(6, '0')}`, BigInt(i)]),
61
+ compareFor(StringType));
62
+ const objects = new Map<string, Uint8Array>();
63
+ const sink = async (bytes: Uint8Array): Promise<string> => {
64
+ const hash = sha256Hex(bytes);
65
+ objects.set(hash, bytes);
66
+ return hash;
67
+ };
68
+ const whole = await writeCollectionManifest(TableType, [{ elements: value.entries() }], sink);
69
+ const entries = decodeCollectionManifest(whole).entries;
70
+ const half = entries.length >> 1;
71
+
72
+ // The first half as the segments they are, each with the fence that
73
+ // followed it; the rest as elements, from the first key of the second half.
74
+ const reads: string[] = [];
75
+ const carried: CollectionSegmentRef[] = entries.slice(0, half).map((entry, i) => ({
76
+ count: Number(entry.count),
77
+ fence: entry.fence,
78
+ nextFence: entries[i + 1]!.fence,
79
+ read: () => {
80
+ reads.push(entry.hash);
81
+ return objects.get(entry.hash)!;
82
+ },
83
+ entry,
84
+ }));
85
+ const written = new Set<string>();
86
+ const again = await writeCollectionManifest(TableType, [
87
+ { segments: carried },
88
+ { elements: value.entries(decodeBeast2FenceFor(StringType)(entries[half]!.fence) as string) },
89
+ ], async (bytes) => {
90
+ const hash = await sink(bytes);
91
+ written.add(hash);
92
+ return hash;
93
+ });
94
+
95
+ assert.deepEqual(again, whole, 'the same manifest');
96
+ assert.deepEqual(reads, [], 'the seam is decided by the fence that followed');
97
+ for (const entry of entries.slice(0, half)) assert.ok(!written.has(entry.hash), `segment ${entry.hash.slice(0, 8)} is carried`);
98
+ });
99
+ });
100
+
101
+ describe('encodeDatasetBlob', () => {
102
+ it('encodes a collection without a sink as its paged blob, and any other value whole', async () => {
103
+ const value = new Map([['a', 1n], ['b', 2n]]);
104
+ assert.deepEqual(encodeDatasetBlob(TableType, value), encodeBeast2PagedFor(TableType)(value));
105
+
106
+ const RowType = StructType({ id: IntegerType, name: StringType });
107
+ const row = { id: 1n, name: 'one' };
108
+ const whole = encodeBeast2For(RowType)(row);
109
+ assert.deepEqual(encodeDatasetBlob(RowType, row), whole);
110
+ const sunk: Uint8Array[] = [];
111
+ assert.deepEqual(await encodeDatasetBlob(RowType, row, async (bytes) => {
112
+ sunk.push(bytes);
113
+ return sha256Hex(bytes);
114
+ }), whole, 'a sink changes nothing for a value that is not a collection');
115
+ assert.deepEqual(sunk, []);
116
+ });
117
+ });
@@ -7,33 +7,57 @@
7
7
  * The ONE encoder that decides how a dataset value is stored.
8
8
  *
9
9
  * Collection-rooted values (Array / Set / Dict) are ALWAYS stored segmented
10
- * with a trailing index, at every size: one uniform encoding per logical value,
11
- * so the paged read API can decode just the segments a window touches and the
12
- * key search can bisect the segment fences. Every other root is stored whole.
10
+ * under the content-defined cut rule, at every size: one uniform encoding per
11
+ * logical value, so the paged read API can decode just the segments a window
12
+ * touches and the key search can bisect the segment fences. Every other root is
13
+ * stored whole.
13
14
  *
14
- * This lives in `e3-types` — the floor both `e3` and `e3-core` stand on —
15
- * because the rule has to hold at EVERY door a value enters the store through,
16
- * and it did not. The store path (`e3-core`'s `datasetWrite`) segmented; the
17
- * package export path (`e3`'s `export_`) encoded flat regardless of root kind.
18
- * Since `workspaceDeploy` copies package refs verbatim, a freshly deployed
19
- * collection input pointed at an unindexed blob and could not be paged at all
20
- * — `dataset_not_indexed`, with no whole-decode fallback — until something
21
- * happened to WRITE the dataset, at which point it silently started working.
22
- * A demo poked by hand behaved differently from a workspace only deployed.
15
+ * Given a `sink` — an object store to write through — a collection goes in as
16
+ * **segment objects plus a {@link CollectionManifestType} naming them**, and it
17
+ * is the manifest the dataset ref points at. That is what makes a one-row edit
18
+ * cost one segment: the new manifest names the same objects as the old one bar
19
+ * the segment that changed, and a content-addressed store deduplicates the
20
+ * rest. Without a sink the value is one blob, which is what travels over the
21
+ * wire.
23
22
  *
24
- * So: one branch, one home, and both doors call it.
23
+ * This lives in `e3-types` — the floor both `e3` and `e3-core` stand on —
24
+ * because the rule has to hold at EVERY door a value enters the store through:
25
+ * the store's own door (`e3-core`'s `storeCollection`) and the package export
26
+ * both write collections through {@link writeCollectionManifest}.
25
27
  *
26
28
  * @packageDocumentation
27
29
  */
28
30
 
29
31
  import {
32
+ SortedMap,
33
+ SortedSet,
34
+ compareFor,
30
35
  encodeBeast2For,
31
36
  encodeBeast2PagedFor,
32
37
  isVariant,
38
+ recutBeast2For,
39
+ segmentRuleFor,
33
40
  toEastTypeValue,
41
+ type Beast2RecutPiece,
42
+ type Beast2SegmentRef,
34
43
  type EastType,
35
44
  type EastTypeValue,
36
45
  } from '@elaraai/east';
46
+ import {
47
+ COLLECTION_MANIFEST_KIND,
48
+ encodeCollectionManifest,
49
+ type CollectionManifestEntry,
50
+ } from './collection-manifest.js';
51
+
52
+ /**
53
+ * Writes one object and answers with its content hash.
54
+ *
55
+ * @remarks
56
+ * The whole of the object store this module needs. Content-addressed, so
57
+ * writing bytes already stored is a no-op that returns the same hash — which is
58
+ * exactly how a new state shares its unchanged segments with the old one.
59
+ */
60
+ export type SegmentSink = (bytes: Uint8Array) => Promise<string>;
37
61
 
38
62
  /**
39
63
  * Whether a dataset root type is a collection — the kinds stored segmented +
@@ -46,6 +70,89 @@ export function isCollectionRoot(typeValue: EastTypeValue): boolean {
46
70
  return typeValue.type === 'Array' || typeValue.type === 'Set' || typeValue.type === 'Dict';
47
71
  }
48
72
 
73
+ /** The caller's type argument as an {@link EastTypeValue}. */
74
+ function asTypeValue(type: EastType | EastTypeValue): EastTypeValue {
75
+ return isVariant(type) ? (type as EastTypeValue) : toEastTypeValue(type as EastType);
76
+ }
77
+
78
+ /**
79
+ * A segment a collection's manifest may carry over as it stands: the segment
80
+ * as a re-cut takes it, and the manifest entry that already names it in the
81
+ * store, when it is there.
82
+ */
83
+ export interface CollectionSegmentRef extends Beast2SegmentRef {
84
+ /** The entry naming the segment in the manifest it came from. A carried
85
+ * segment with one is named by it again, never read or written; one
86
+ * without is read and written as it stands. */
87
+ readonly entry?: CollectionManifestEntry;
88
+ }
89
+
90
+ /** A piece of a collection, in order, as {@link writeCollectionManifest}
91
+ * takes it: a run of segments the Writer wrote, under the canonical header
92
+ * for the type, or elements. */
93
+ export type CollectionPiece = Beast2RecutPiece<EastType, CollectionSegmentRef>;
94
+
95
+ /**
96
+ * Write a collection as segment objects and return the manifest naming them.
97
+ *
98
+ * @remarks
99
+ * The pieces are re-cut into the canonical whole (`recutBeast2For`): a segment
100
+ * the whole shares with its piece is carried over, and everything else — the
101
+ * seams between pieces, and pieces given as elements — is cut by the rule and
102
+ * written. What comes out is what the Writer writes for the whole value,
103
+ * whichever pieces it came in, so two equal values have one manifest.
104
+ *
105
+ * Every segment is written under the canonical header for the type, the
106
+ * Writer's own: a piece's segments are carried over only when that is the
107
+ * header they are under, which is the caller's to establish.
108
+ *
109
+ * @param type - The collection type (Array / Set / Dict)
110
+ * @param pieces - The collection's pieces, in order
111
+ * @param sink - Writes one object and returns its hash
112
+ * @returns The manifest's bytes — the caller stores them, and its hash is the
113
+ * dataset's content address
114
+ * @throws {Error} When a piece's elements do not ascend (Set / Dict), a
115
+ * segment's blob is not the one segment its reference describes, or the sink
116
+ * rejects.
117
+ */
118
+ export async function writeCollectionManifest(
119
+ type: EastType | EastTypeValue,
120
+ pieces: Iterable<CollectionPiece> | AsyncIterable<CollectionPiece>,
121
+ sink: SegmentSink,
122
+ ): Promise<Uint8Array> {
123
+ const typeValue = asTypeValue(type);
124
+ const entries: CollectionManifestEntry[] = [];
125
+ // Frames deflate on the worker pool once the value is large enough to be
126
+ // worth it; the pool holds the frames in flight and nothing more.
127
+ const recut = recutBeast2For<EastType, CollectionSegmentRef>(typeValue, { parallel: true });
128
+ const stats = await recut(pieces, {
129
+ written: async (segment) => {
130
+ entries.push({
131
+ hash: await sink(segment.blob),
132
+ fence: segment.fence,
133
+ count: BigInt(segment.count),
134
+ bytes: BigInt(segment.blob.byteLength),
135
+ });
136
+ },
137
+ carried: async (ref) => {
138
+ if (ref.entry !== undefined) {
139
+ entries.push(ref.entry);
140
+ return;
141
+ }
142
+ const blob = await ref.read();
143
+ entries.push({ hash: await sink(blob), fence: ref.fence, count: BigInt(ref.count), bytes: BigInt(blob.byteLength) });
144
+ },
145
+ });
146
+ return encodeCollectionManifest({
147
+ kind: COLLECTION_MANIFEST_KIND,
148
+ level: 0n,
149
+ type: typeValue,
150
+ rule: segmentRuleFor(typeValue),
151
+ header: await sink(stats.header),
152
+ entries,
153
+ });
154
+ }
155
+
49
156
  /**
50
157
  * Encode a dataset value for the object store.
51
158
  *
@@ -54,15 +161,44 @@ export function isCollectionRoot(typeValue: EastTypeValue): boolean {
54
161
  * encoder themselves: a value that reaches the store unsegmented is one the
55
162
  * paged endpoints refuse, and nothing on the read path can repair it.
56
163
  *
164
+ * With a `sink`, a collection root is written as segment objects and the
165
+ * returned bytes are the manifest naming them; a non-collection root ignores
166
+ * the sink and returns its whole blob, which is what it has always been.
167
+ * Without one, a collection is its canonical segmented blob — the form it
168
+ * travels over the wire in.
169
+ *
57
170
  * @param type - The dataset's declared type (an `EastType` or its homoiconic value)
58
171
  * @param value - The value to encode
59
- * @returns The beast2 bytes to store — segmented + indexed for a collection root
172
+ * @param sink - Writes one object and returns its hash
173
+ * @returns The beast2 bytes to store — the manifest, for a collection written
174
+ * through a sink; otherwise the value's own blob
60
175
  */
61
- export function encodeDatasetBlob(type: EastType | EastTypeValue, value: unknown): Uint8Array {
62
- const typeValue: EastTypeValue = isVariant(type)
63
- ? (type as EastTypeValue)
64
- : toEastTypeValue(type as EastType);
65
- return isCollectionRoot(typeValue)
66
- ? encodeBeast2PagedFor(typeValue)(value)
67
- : encodeBeast2For(typeValue)(value);
176
+ export function encodeDatasetBlob(type: EastType | EastTypeValue, value: unknown): Uint8Array;
177
+ export function encodeDatasetBlob(type: EastType | EastTypeValue, value: unknown, sink: SegmentSink): Promise<Uint8Array>;
178
+ export function encodeDatasetBlob(
179
+ type: EastType | EastTypeValue,
180
+ value: unknown,
181
+ sink?: SegmentSink,
182
+ ): Uint8Array | Promise<Uint8Array> {
183
+ const typeValue = asTypeValue(type);
184
+ if (!isCollectionRoot(typeValue)) {
185
+ const blob = encodeBeast2For(typeValue)(value);
186
+ return sink === undefined ? blob : Promise.resolve(blob);
187
+ }
188
+ if (sink === undefined) return encodeBeast2PagedFor(typeValue)(value);
189
+ return writeCollectionManifest(typeValue, [{ elements: canonicalElements(typeValue, value) }], sink);
190
+ }
191
+
192
+ /** A collection value's elements in canonical order: an Array's as they
193
+ * stand, a Set's or a Dict's ascending in East order — a plain `Set` or
194
+ * `Map` iterates in insertion order, and is sorted first. */
195
+ function canonicalElements(typeValue: EastTypeValue, value: unknown): Iterable<unknown> {
196
+ if (typeValue.type === 'Array') return value as unknown[];
197
+ if (typeValue.type === 'Set') {
198
+ return value instanceof SortedSet ? value : [...(value as Set<unknown>)].sort(compareFor(typeValue.value as EastTypeValue));
199
+ }
200
+ const cmp = compareFor((typeValue.value as { key: EastTypeValue }).key);
201
+ return value instanceof SortedMap
202
+ ? (value as SortedMap<unknown, unknown>).entries()
203
+ : [...(value as Map<unknown, unknown>).entries()].sort((a, b) => cmp(a[0], b[0]));
68
204
  }
@@ -6,9 +6,9 @@
6
6
  /**
7
7
  * Per-dataset reference types for reactive dataflow.
8
8
  *
9
- * Each dataset in a workspace has its own `.ref` file instead of being
10
- * part of a single root tree. This enables concurrent writes and
11
- * reactive re-execution.
9
+ * Each dataset in a workspace has its own ref instead of being part of a
10
+ * single root tree. This enables concurrent writes and reactive
11
+ * re-execution.
12
12
  *
13
13
  * A DatasetRef tracks:
14
14
  * - The current value hash (or unassigned/null state)
@@ -48,7 +48,8 @@ export type VersionVector = ValueTypeOf<typeof VersionVectorType>;
48
48
  /**
49
49
  * Per-dataset reference stored in workspace data files.
50
50
  *
51
- * Each dataset has a `.ref` file at `workspaces/<ws>/data/<path>.ref`.
51
+ * A local repository keeps each dataset's ref at
52
+ * `workspaces/<ws>/data/<path>.beast2`, beside the revision its write minted.
52
53
  * The ref tracks the dataset's current state and version provenance.
53
54
  *
54
55
  * Variants:
@@ -14,8 +14,9 @@
14
14
  * implementation code (via lockfile + sdist/tarball hashes) into execution
15
15
  * cache identity.
16
16
  *
17
- * All fields hold object-store hashes, never inline file contents: the blobs
18
- * ride the existing content-addressed object store and transfer machinery.
17
+ * All fields hold object-store hashes, never inline file contents: each names
18
+ * an object holding the file's bytes as a beast2 Blob, which rides the
19
+ * content-addressed object store and transfer machinery.
19
20
  */
20
21
 
21
22
  import { ArrayType, OptionType, StringType, StructType, ValueTypeOf, VariantType } from '@elaraai/east';
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Copyright (c) 2025 Elara AI Pty Ltd
3
+ * Dual-licensed under AGPL-3.0 and commercial license. See LICENSE for details.
4
+ */
5
+
6
+ /**
7
+ * Execution status wire: the typed outcomes with their peaks, a split task's
8
+ * plan and whether the execution is a unit, what each keeps from gc, and the
9
+ * refusal of a record an older e3 wrote before `cancelled` and `interrupted`
10
+ * were cases of it, before a runner's peak was recorded, before a split task's
11
+ * plan was, or before a record said whether it is a unit.
12
+ */
13
+
14
+ import { describe, it } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import { ArrayType, DateTimeType, IntegerType, OptionType, StringType, StructType, VariantType, encodeBeast2For, none, some, variant } from '@elaraai/east';
17
+ import { ExecutionStatusType, decodeExecutionStatus, executionStatusRoots, type ExecutionStatus } from './execution.js';
18
+
19
+ /** The four-case wire every record had before the typed outcomes. */
20
+ const PreOutcomeExecutionStatusType = VariantType({
21
+ running: StructType({
22
+ executionId: StringType, inputHashes: ArrayType(StringType), startedAt: DateTimeType,
23
+ pid: IntegerType, pidStartTime: IntegerType, bootId: StringType,
24
+ }),
25
+ success: StructType({
26
+ executionId: StringType, inputHashes: ArrayType(StringType), outputHash: StringType,
27
+ startedAt: DateTimeType, completedAt: DateTimeType,
28
+ }),
29
+ failed: StructType({
30
+ executionId: StringType, inputHashes: ArrayType(StringType), startedAt: DateTimeType,
31
+ completedAt: DateTimeType, exitCode: IntegerType,
32
+ }),
33
+ error: StructType({
34
+ executionId: StringType, inputHashes: ArrayType(StringType), startedAt: DateTimeType,
35
+ completedAt: DateTimeType, message: StringType,
36
+ }),
37
+ });
38
+
39
+ describe('ExecutionStatusType', () => {
40
+ const stopped = {
41
+ executionId: '0199-a',
42
+ inputHashes: ['a'.repeat(64)],
43
+ startedAt: new Date(1000),
44
+ completedAt: new Date(2000),
45
+ };
46
+ const encodeOlder = encodeBeast2For(PreOutcomeExecutionStatusType);
47
+
48
+ it('round-trips the typed outcomes, with a runner\'s peak or without one, a task\'s own or a unit\'s', () => {
49
+ const own = { ...stopped, unit: false };
50
+ const statuses: ExecutionStatus[] = [
51
+ variant('success', { ...own, outputHash: 'b'.repeat(64), peakBytes: some(96n * 1024n ** 2n), plan: none }),
52
+ variant('success', { ...own, outputHash: 'b'.repeat(64), peakBytes: none, plan: some('c'.repeat(64)) }),
53
+ variant('success', { ...own, outputHash: 'b'.repeat(64), peakBytes: some(64n * 1024n ** 2n), plan: none, unit: true }),
54
+ variant('running', { executionId: '0199-b', inputHashes: ['a'.repeat(64)], startedAt: new Date(1000), pid: 4242n, pidStartTime: 7n, bootId: 'boot', unit: true }),
55
+ variant('failed', { ...own, exitCode: 1n, peakBytes: some(412n * 1024n ** 2n) }),
56
+ variant('failed', { ...own, exitCode: -1n, peakBytes: none }),
57
+ variant('cancelled', own),
58
+ variant('interrupted', { ...own, pid: 4242n }),
59
+ variant('error', { ...own, message: 'Failed to read output: no such file' }),
60
+ ];
61
+ const encode = encodeBeast2For(ExecutionStatusType);
62
+ for (const status of statuses) assert.deepEqual(decodeExecutionStatus(encode(status)), status);
63
+ });
64
+
65
+ it('keeps from gc a success\'s output, and a split task\'s last plan beside it', () => {
66
+ const success = { ...stopped, outputHash: 'b'.repeat(64), peakBytes: none, unit: false };
67
+ assert.deepEqual(executionStatusRoots(variant('success', { ...success, plan: none })), ['b'.repeat(64)]);
68
+ assert.deepEqual(executionStatusRoots(variant('success', { ...success, plan: some('c'.repeat(64)) })), ['b'.repeat(64), 'c'.repeat(64)]);
69
+ assert.deepEqual(executionStatusRoots(variant('failed', { ...stopped, exitCode: 1n, peakBytes: none, unit: false })), []);
70
+ assert.deepEqual(executionStatusRoots(variant('cancelled', { ...stopped, unit: false })), []);
71
+ });
72
+
73
+ it('refuses a record an older e3 wrote, naming the fix', () => {
74
+ const refusal = /^Error: the execution status does not decode: an older e3 wrote this repository — re-create it: deploy again and import its data again \(/;
75
+ const records = [
76
+ variant('error', { ...stopped, message: 'cancelled: e3 stopped the runner because the run was aborted' }),
77
+ variant('success', { ...stopped, outputHash: 'b'.repeat(64) }),
78
+ ] as const;
79
+ for (const record of records) {
80
+ assert.throws(() => decodeExecutionStatus(encodeOlder(record)), refusal);
81
+ }
82
+ // The typed outcomes, before a runner's peak was recorded.
83
+ const withoutPeaks = VariantType({
84
+ ...PreOutcomeExecutionStatusType.cases,
85
+ cancelled: StructType({ executionId: StringType, inputHashes: ArrayType(StringType), startedAt: DateTimeType, completedAt: DateTimeType }),
86
+ interrupted: StructType({
87
+ executionId: StringType, inputHashes: ArrayType(StringType), startedAt: DateTimeType, completedAt: DateTimeType, pid: IntegerType,
88
+ }),
89
+ });
90
+ assert.throws(() => decodeExecutionStatus(encodeBeast2For(withoutPeaks)(variant('success', { ...stopped, outputHash: 'b'.repeat(64) }))), refusal);
91
+ // With peaks, before a split task's plan was recorded.
92
+ const withoutPlans = VariantType({
93
+ ...ExecutionStatusType.cases,
94
+ success: StructType({
95
+ executionId: StringType, inputHashes: ArrayType(StringType), outputHash: StringType,
96
+ startedAt: DateTimeType, completedAt: DateTimeType, peakBytes: OptionType(IntegerType),
97
+ }),
98
+ });
99
+ assert.throws(() => decodeExecutionStatus(encodeBeast2For(withoutPlans)(variant('success', { ...stopped, outputHash: 'b'.repeat(64), peakBytes: none }))), refusal);
100
+ // With plans, before a record said whether it is a unit of a split task.
101
+ const withoutUnits = VariantType({
102
+ ...ExecutionStatusType.cases,
103
+ success: StructType({
104
+ executionId: StringType, inputHashes: ArrayType(StringType), outputHash: StringType,
105
+ startedAt: DateTimeType, completedAt: DateTimeType, peakBytes: OptionType(IntegerType), plan: OptionType(StringType),
106
+ }),
107
+ });
108
+ assert.throws(() => decodeExecutionStatus(encodeBeast2For(withoutUnits)(variant('success', { ...stopped, outputHash: 'b'.repeat(64), peakBytes: none, plan: none }))), refusal);
109
+ });
110
+
111
+ it('throws for bytes of no status shape', () => {
112
+ assert.throws(() => decodeExecutionStatus(encodeBeast2For(IntegerType)(7n)), /the execution status does not decode/);
113
+ });
114
+ });