@elaraai/e3 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 (99) hide show
  1. package/README.md +11 -2
  2. package/dist/src/browser.d.ts +5 -4
  3. package/dist/src/browser.d.ts.map +1 -1
  4. package/dist/src/browser.js +3 -2
  5. package/dist/src/browser.js.map +1 -1
  6. package/dist/src/dataset-file.d.ts +17 -19
  7. package/dist/src/dataset-file.d.ts.map +1 -1
  8. package/dist/src/dataset-file.js +26 -45
  9. package/dist/src/dataset-file.js.map +1 -1
  10. package/dist/src/export.d.ts +2 -3
  11. package/dist/src/export.d.ts.map +1 -1
  12. package/dist/src/export.js +150 -80
  13. package/dist/src/export.js.map +1 -1
  14. package/dist/src/export.spec.js +177 -57
  15. package/dist/src/export.spec.js.map +1 -1
  16. package/dist/src/function.spec.js +29 -58
  17. package/dist/src/function.spec.js.map +1 -1
  18. package/dist/src/functions-link.spec.js +19 -17
  19. package/dist/src/functions-link.spec.js.map +1 -1
  20. package/dist/src/index.d.ts +56 -8
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +11 -4
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/input.d.ts +8 -5
  25. package/dist/src/input.d.ts.map +1 -1
  26. package/dist/src/input.js +8 -5
  27. package/dist/src/input.js.map +1 -1
  28. package/dist/src/migration.d.ts +172 -0
  29. package/dist/src/migration.d.ts.map +1 -0
  30. package/dist/src/migration.js +108 -0
  31. package/dist/src/migration.js.map +1 -0
  32. package/dist/src/migration.spec.d.ts +6 -0
  33. package/dist/src/migration.spec.d.ts.map +1 -0
  34. package/dist/src/migration.spec.js +197 -0
  35. package/dist/src/migration.spec.js.map +1 -0
  36. package/dist/src/mutation-programs.spec.d.ts +6 -0
  37. package/dist/src/mutation-programs.spec.d.ts.map +1 -0
  38. package/dist/src/mutation-programs.spec.js +310 -0
  39. package/dist/src/mutation-programs.spec.js.map +1 -0
  40. package/dist/src/mutation.d.ts +117 -9
  41. package/dist/src/mutation.d.ts.map +1 -1
  42. package/dist/src/mutation.js +104 -46
  43. package/dist/src/mutation.js.map +1 -1
  44. package/dist/src/open-beast-link.spec.js +10 -8
  45. package/dist/src/open-beast-link.spec.js.map +1 -1
  46. package/dist/src/output.d.ts +117 -0
  47. package/dist/src/output.d.ts.map +1 -0
  48. package/dist/src/output.js +119 -0
  49. package/dist/src/output.js.map +1 -0
  50. package/dist/src/package.d.ts +7 -5
  51. package/dist/src/package.d.ts.map +1 -1
  52. package/dist/src/package.js +98 -8
  53. package/dist/src/package.js.map +1 -1
  54. package/dist/src/package.spec.d.ts +6 -0
  55. package/dist/src/package.spec.d.ts.map +1 -0
  56. package/dist/src/package.spec.js +16 -0
  57. package/dist/src/package.spec.js.map +1 -0
  58. package/dist/src/record-guards.d.ts +41 -0
  59. package/dist/src/record-guards.d.ts.map +1 -0
  60. package/dist/src/record-guards.js +44 -0
  61. package/dist/src/record-guards.js.map +1 -0
  62. package/dist/src/record-index.d.ts +129 -0
  63. package/dist/src/record-index.d.ts.map +1 -0
  64. package/dist/src/record-index.js +144 -0
  65. package/dist/src/record-index.js.map +1 -0
  66. package/dist/src/record-index.spec.d.ts +6 -0
  67. package/dist/src/record-index.spec.d.ts.map +1 -0
  68. package/dist/src/record-index.spec.js +160 -0
  69. package/dist/src/record-index.spec.js.map +1 -0
  70. package/dist/src/record-programs.d.ts +128 -0
  71. package/dist/src/record-programs.d.ts.map +1 -0
  72. package/dist/src/record-programs.js +480 -0
  73. package/dist/src/record-programs.js.map +1 -0
  74. package/dist/src/record.d.ts.map +1 -1
  75. package/dist/src/record.js +2 -0
  76. package/dist/src/record.js.map +1 -1
  77. package/dist/src/record.spec.js +71 -13
  78. package/dist/src/record.spec.js.map +1 -1
  79. package/dist/src/runner.d.ts +6 -9
  80. package/dist/src/runner.d.ts.map +1 -1
  81. package/dist/src/runner.js +9 -25
  82. package/dist/src/runner.js.map +1 -1
  83. package/dist/src/runner.spec.js +16 -34
  84. package/dist/src/runner.spec.js.map +1 -1
  85. package/dist/src/sha256.d.ts +7 -4
  86. package/dist/src/sha256.d.ts.map +1 -1
  87. package/dist/src/sha256.js +26 -18
  88. package/dist/src/sha256.js.map +1 -1
  89. package/dist/src/sha256.spec.js +10 -0
  90. package/dist/src/sha256.spec.js.map +1 -1
  91. package/dist/src/task.d.ts +116 -251
  92. package/dist/src/task.d.ts.map +1 -1
  93. package/dist/src/task.js +169 -435
  94. package/dist/src/task.js.map +1 -1
  95. package/dist/src/task.spec.js +174 -489
  96. package/dist/src/task.spec.js.map +1 -1
  97. package/dist/src/types.d.ts +215 -23
  98. package/dist/src/types.d.ts.map +1 -1
  99. package/package.json +4 -4
@@ -4,10 +4,12 @@
4
4
  */
5
5
  import { describe, it } from 'node:test';
6
6
  import assert from 'node:assert';
7
- import { East, StringType, IntegerType, FloatType, StructType, DictType, SetType, ArrayType, variant, decodeEastIR } from '@elaraai/east';
8
- import { TASK_KIND_PARTITION, TASK_KIND_STREAM, decodePartitionTaskMetadata, decodeStreamTaskMetadata } from '@elaraai/e3-types';
9
- import { task, partitionTask, streamTask } from './task.js';
7
+ import { East, StringType, IntegerType, FloatType, StructType, DictType, SetType, ArrayType, NullType, FunctionType, variant, isTypeValueEqual, toEastTypeValue } from '@elaraai/east';
8
+ import { task, customTask, partition, streamTask } from './task.js';
9
+ import { output } from './output.js';
10
10
  import { input } from './input.js';
11
+ /** Whether two East types are the same type. */
12
+ const sameType = (a, b) => isTypeValueEqual(toEastTypeValue(a), toEastTypeValue(b));
11
13
  describe('task', () => {
12
14
  describe('type inference', () => {
13
15
  it('accepts single input dataset', () => {
@@ -24,22 +26,14 @@ describe('task', () => {
24
26
  assert.strictEqual(repeat_greet.kind, 'task');
25
27
  assert.strictEqual(repeat_greet.name, 'repeat_greet');
26
28
  });
27
- it('accepts three input datasets', () => {
28
- const a_input = input('a', StringType, variant('value', 'a'));
29
- const b_input = input('b', IntegerType, variant('value', 1n));
30
- const c_input = input('c', FloatType, variant('value', 1.0));
31
- const combine = task('combine', [a_input, b_input, c_input], East.function([StringType, IntegerType, FloatType], StringType, ($, a, _b, _c) => $.return(a)));
32
- assert.strictEqual(combine.kind, 'task');
33
- assert.strictEqual(combine.inputs.length, 4); // 3 inputs + function_ir
34
- });
35
- it('accepts four input datasets (tuple type preservation)', () => {
29
+ it('accepts four input datasets (tuple type preservation), each an input of the body', () => {
36
30
  const a_input = input('a', StringType, variant('value', 'a'));
37
31
  const b_input = input('b', IntegerType, variant('value', 1n));
38
32
  const c_input = input('c', FloatType, variant('value', 1.0));
39
33
  const d_input = input('d', StringType, variant('value', 'd'));
40
34
  const combine_four = task('combine_four', [a_input, b_input, c_input, d_input], East.function([StringType, IntegerType, FloatType, StringType], StringType, ($, a, _b, _c, _d) => $.return(a)));
41
35
  assert.strictEqual(combine_four.kind, 'task');
42
- assert.strictEqual(combine_four.inputs.length, 5); // 4 inputs + function_ir
36
+ assert.deepStrictEqual(combine_four.inputs, [a_input, b_input, c_input, d_input]);
43
37
  });
44
38
  it('accepts struct type inputs', () => {
45
39
  const PersonType = StructType({
@@ -61,30 +55,14 @@ describe('task', () => {
61
55
  // shout depends on greet's output
62
56
  assert.ok(shout.deps.has(greet.output));
63
57
  });
64
- it('allows mixing inputs and task outputs', () => {
65
- const name_input = input('name', StringType, variant('value', 'World'));
66
- const suffix_input = input('suffix', StringType, variant('value', '!'));
67
- const greet = task('greet', [name_input], East.function([StringType], StringType, ($, name) => $.return(East.str `Hello, ${name}`)));
68
- const add_suffix = task('add_suffix', [greet.output, suffix_input], East.function([StringType, StringType], StringType, ($, greeting, suffix) => $.return(East.str `${greeting}${suffix}`)));
69
- assert.strictEqual(add_suffix.kind, 'task');
70
- assert.strictEqual(add_suffix.inputs.length, 3); // function_ir + 2 inputs
71
- });
72
58
  });
73
59
  describe('async functions', () => {
74
60
  it('accepts an async function', () => {
75
61
  const name_input = input('name', StringType, variant('value', 'World'));
76
62
  const greet = task('greet', [name_input], East.asyncFunction([StringType], StringType, ($, name) => $.return(East.str `Hello, ${name}!`)));
77
63
  assert.strictEqual(greet.kind, 'task');
78
- assert.strictEqual(greet.name, 'greet');
79
- assert.strictEqual(greet.output.kind, 'dataset');
80
64
  assert.strictEqual(greet.output.type, StringType);
81
- });
82
- it('accepts async function with multiple inputs', () => {
83
- const name_input = input('name', StringType, variant('value', 'World'));
84
- const count_input = input('count', IntegerType, variant('value', 1n));
85
- const repeat_greet = task('repeat_greet', [name_input, count_input], East.asyncFunction([StringType, IntegerType], StringType, ($, name, _count) => $.return(East.str `Hello, ${name}!`)));
86
- assert.strictEqual(repeat_greet.kind, 'task');
87
- assert.strictEqual(repeat_greet.inputs.length, 3); // function_ir + 2 inputs
65
+ assert.strictEqual(greet.body.kind, 'east');
88
66
  });
89
67
  });
90
68
  describe('task structure', () => {
@@ -97,383 +75,92 @@ describe('task', () => {
97
75
  variant('field', 'output'),
98
76
  ]);
99
77
  });
100
- it('includes function_ir as first input', () => {
78
+ it('carries the function as its program, returning the output, in the data role', () => {
101
79
  const name_input = input('name', StringType, variant('value', 'World'));
102
- const greet = task('greet', [name_input], East.function([StringType], StringType, ($, name) => $.return(name)));
103
- // First input should be the function_ir dataset
104
- assert.strictEqual(greet.inputs[0].name, 'function_ir');
80
+ const greet = task('greet', [name_input], East.function([StringType], StringType, ($, name) => $.return(East.str `Hello, ${name}!`)));
81
+ // The program is the body itself — no dataset holds it, and the inputs
82
+ // are the datasets alone.
83
+ assert.deepStrictEqual(greet.inputs, [name_input]);
84
+ assert.strictEqual(greet.outputKind, undefined);
85
+ assert.deepStrictEqual(greet.role, variant('data', null));
86
+ assert.strictEqual(greet.body.kind, 'east');
87
+ if (greet.body.kind !== 'east')
88
+ return;
89
+ const run = greet.body.program.compile([]);
90
+ assert.strictEqual(run('World'), 'Hello, World!');
105
91
  });
106
- it('preserves custom runner config', () => {
92
+ it('keeps a custom runner and the role it is given', () => {
107
93
  const name_input = input('name', StringType, variant('value', 'World'));
94
+ const role = variant('ui', { paths: [name_input.path], functions: [], records: [], pages: [] });
108
95
  const greet = task('greet', [name_input], East.function([StringType], StringType, ($, name) => $.return(name)), {
109
96
  runner: { runtime: 'custom', command: ['uv', 'run', 'east-py', 'run', '-p', 'east-py-std'] },
97
+ role,
110
98
  });
111
- assert.strictEqual(greet.kind, 'task');
112
- // The runner is encoded in the command, we just verify the task was created
113
- });
114
- });
115
- });
116
- describe('partitionTask', () => {
117
- const SaleType = StructType({ sku: StringType, qty: IntegerType });
118
- const SaleKeyType = StructType({ sku: StringType, period: IntegerType });
119
- it('builds a partition-kind task with the metadata spec', () => {
120
- const sales = input('sales', DictType(SaleKeyType, SaleType));
121
- const totals = input('rates', DictType(StringType, FloatType));
122
- const cleaned = partitionTask('cleaned', {
123
- partitions: [sales],
124
- inputs: [totals],
125
- output: DictType(SaleKeyType, SaleType),
126
- targetPartitionBytes: 1024,
127
- }, ($, slice, _rates) => $.return(slice));
128
- assert.strictEqual(cleaned.kind, 'task');
129
- assert.strictEqual(cleaned.taskKind, TASK_KIND_PARTITION);
130
- // function_ir + 1 partition + 1 ordinary input
131
- assert.strictEqual(cleaned.inputs.length, 3);
132
- assert.strictEqual(cleaned.inputs[0].name, 'function_ir');
133
- assert.strictEqual(cleaned.inputs[1], sales);
134
- assert.strictEqual(cleaned.inputs[2], totals);
135
- assert.deepStrictEqual(cleaned.output.path, [
136
- variant('field', 'tasks'),
137
- variant('field', 'cleaned'),
138
- variant('field', 'output'),
139
- ]);
140
- const meta = decodePartitionTaskMetadata(cleaned.metadata);
141
- assert.strictEqual(meta.partitions, 1n);
142
- assert.strictEqual(meta.by.type, 'none');
143
- assert.strictEqual(meta.combine.type, 'none');
144
- assert.strictEqual(meta.targetPartitionBytes, 1024n);
145
- });
146
- it('carries by and combine as decodable EastIR bundles', () => {
147
- const sales = input('sales', DictType(SaleKeyType, IntegerType));
148
- const totals = partitionTask('totals', {
149
- partitions: [sales],
150
- by: (_$, key) => key.sku,
151
- output: DictType(StringType, IntegerType),
152
- combine: ($, a, b) => {
153
- // Partials are frozen task inputs — fold into a copy.
154
- const acc = $.let(a.copy());
155
- $(acc.mergeAll(b, ($, v1, v2) => v1.add(v2), ($, _k) => 0n));
156
- $.return(acc);
157
- },
158
- }, ($, slice) => slice.toArray(($, v, k) => ({ sku: k.sku, v }))
159
- .groupReduce(($, x) => x.sku, ($, _k) => 0n, ($, acc, x) => acc.add(x.v)));
160
- const meta = decodePartitionTaskMetadata(totals.metadata);
161
- assert.strictEqual(meta.partitions, 1n);
162
- assert.strictEqual(meta.by.type, 'some');
163
- assert.strictEqual(meta.combine.type, 'some');
164
- // Both blobs decode as free East functions with the declared signatures.
165
- const byIr = decodeEastIR(meta.by.type === 'some' ? meta.by.value : new Uint8Array());
166
- assert.strictEqual(byIr.ir.value.parameters.length, 1);
167
- const combineIr = decodeEastIR(meta.combine.type === 'some' ? meta.combine.value : new Uint8Array());
168
- assert.strictEqual(combineIr.ir.value.parameters.length, 2);
169
- });
170
- it('accepts a whole-key identity by and a leading-prefix struct by', () => {
171
- const sales = input('sales', DictType(SaleKeyType, IntegerType));
172
- const identity = partitionTask('by_identity', {
173
- partitions: [sales],
174
- by: (_$, key) => key,
175
- output: DictType(SaleKeyType, IntegerType),
176
- }, ($, slice) => $.return(slice));
177
- assert.strictEqual(decodePartitionTaskMetadata(identity.metadata).by.type, 'some');
178
- const prefix = partitionTask('by_prefix', {
179
- partitions: [sales],
180
- by: (_$, key) => ({ sku: key.sku }),
181
- output: DictType(SaleKeyType, IntegerType),
182
- }, ($, slice) => $.return(slice));
183
- assert.strictEqual(decodePartitionTaskMetadata(prefix.metadata).by.type, 'some');
184
- });
185
- it('rejects a by that is not a leading key prefix, naming the dataset and its field order', () => {
186
- const sales = input('sales', DictType(SaleKeyType, IntegerType));
187
- assert.throws(() => partitionTask('bad_by', {
188
- partitions: [sales],
189
- by: (_$, key) => key.period,
190
- output: DictType(SaleKeyType, IntegerType),
191
- }, ($, slice) => $.return(slice)), /not a leading prefix of partitioned dataset 'sales' key field order \(sku, period\)/);
192
- });
193
- it('rejects unsupported by projection shapes, echoing the accepted shapes', () => {
194
- const sales = input('sales', DictType(SaleKeyType, IntegerType));
195
- assert.throws(() => partitionTask('computed_by', {
196
- partitions: [sales],
197
- by: (_$, key) => East.str `${key.sku}-x`,
198
- output: DictType(SaleKeyType, IntegerType),
199
- }, ($, slice) => $.return(slice)), /`by` must project a leading prefix of the partition key — accepted shapes: the key itself, a leading field/);
200
- });
201
- describe('`by` nested leading-field paths', () => {
202
- const NestedKeyType = StructType({
203
- head: StructType({ region: StringType, store: IntegerType }),
204
- seq: IntegerType,
99
+ // A custom runtime runs the same program, with run's arguments.
100
+ assert.strictEqual(greet.body.kind, 'east');
101
+ assert.deepStrictEqual(greet.runner, { runtime: 'custom', command: ['uv', 'run', 'east-py', 'run', '-p', 'east-py-std'] });
102
+ assert.strictEqual(greet.role, role);
205
103
  });
206
- it('accepts a nested path that follows the first-field spine', () => {
207
- const sales = input('nsales', DictType(NestedKeyType, IntegerType));
208
- // head is the key's first field and region is head's first field, so
209
- // projecting key.head.region is monotone in canonical key order.
210
- const nested = partitionTask('by_nested', {
211
- partitions: [sales],
212
- by: (_$, key) => key.head.region,
213
- output: DictType(NestedKeyType, IntegerType),
214
- }, ($, slice) => $.return(slice));
215
- assert.strictEqual(decodePartitionTaskMetadata(nested.metadata).by.type, 'some');
216
- // One level of the same spine (a whole-struct leading field).
217
- const oneLevel = partitionTask('by_nested_head', {
218
- partitions: [sales],
219
- by: (_$, key) => key.head,
220
- output: DictType(NestedKeyType, IntegerType),
221
- }, ($, slice) => $.return(slice));
222
- assert.strictEqual(decodePartitionTaskMetadata(oneLevel.metadata).by.type, 'some');
223
- });
224
- it('rejects a nested path that leaves the first-field spine, naming the level', () => {
225
- const sales = input('nsales2', DictType(NestedKeyType, IntegerType));
226
- assert.throws(() => partitionTask('bad_nested', {
227
- partitions: [sales],
228
- by: (_$, key) => key.head.store,
229
- output: DictType(NestedKeyType, IntegerType),
230
- }, ($, slice) => $.return(slice)), /path \(key\.head\.store\) reads 'store', which is not the first field of partitioned dataset 'nsales2' key level \(region, store\)/);
231
- // A non-leading top-level field stays rejected with the prefix error.
232
- assert.throws(() => partitionTask('bad_nested_top', {
233
- partitions: [sales],
234
- by: (_$, key) => key.seq,
235
- output: DictType(NestedKeyType, IntegerType),
236
- }, ($, slice) => $.return(slice)), /projects \(seq\), which is not a leading prefix of partitioned dataset 'nsales2' key field order \(head, seq\)/);
237
- });
238
- it('ByResult rejects non-projection returns at compile time', () => {
239
- const sales = input('nsales4', DictType(NestedKeyType, IntegerType));
240
- const out = DictType(NestedKeyType, IntegerType);
241
- // Thunks only — nothing runs; the @ts-expect-error directives pin the
242
- // TYPE-level rejections (the build fails if any of them stops erroring).
243
- const bad1 = () => partitionTask('t_bad1', {
244
- partitions: [sales],
245
- // @ts-expect-error a bare host value is not a key projection
246
- by: () => 42,
247
- output: out,
248
- }, ($, s) => $.return(s));
249
- const bad2 = () => partitionTask('t_bad2', {
250
- partitions: [sales],
251
- // @ts-expect-error `r` is not a field of the partition key
252
- by: (_$, k) => ({ r: k.head }),
253
- output: out,
254
- }, ($, s) => $.return(s));
255
- const bad3 = () => partitionTask('t_bad3', {
256
- partitions: [sales],
257
- // @ts-expect-error `seq` requires an integer expression, not the head struct
258
- by: (_$, k) => ({ seq: k.head }),
259
- output: out,
260
- }, ($, s) => $.return(s));
261
- assert.ok(bad1 !== undefined && bad2 !== undefined && bad3 !== undefined);
262
- });
263
- it('rejects a struct literal over nested paths (deliberately unsupported)', () => {
264
- const sales = input('nsales3', DictType(NestedKeyType, IntegerType));
265
- assert.throws(() => partitionTask('bad_struct_nested', {
266
- partitions: [sales],
267
- // ByResult already rejects this shape at COMPILE time (`r` is not
268
- // a key field) — the cast exercises the build-time backstop that
269
- // guards untyped/JS callers.
270
- by: ((_$, key) => ({ r: key.head.region })),
271
- output: DictType(NestedKeyType, IntegerType),
272
- }, ($, slice) => $.return(slice)), /accepted shapes: the key itself/);
104
+ it('refuses an input marked with e3.partition, which only a stream task takes', () => {
105
+ const sales = input('sales', DictType(StringType, FloatType));
106
+ const total = East.function([DictType(StringType, FloatType)], FloatType, ($, s) => s.sum());
107
+ // The type refuses it; the runtime check names the task for a caller
108
+ // outside TypeScript's reach.
109
+ // @ts-expect-error a partitioned input is not a dataset
110
+ const typed = () => task('total', [partition(sales)], total);
111
+ assert.ok(typed !== undefined);
112
+ const untypedTask = task;
113
+ assert.throws(() => untypedTask('total', [partition(sales)], total), /^Error: task 'total': input 'sales' is marked with e3\.partition, which only e3\.streamTask takes — pass the dataset itself$/);
273
114
  });
274
115
  });
275
- it('rejects non-collection partitions and mixed-kind co-partitioning', () => {
276
- const scalar = input('scalar', IntegerType, variant('value', 1n));
277
- const sales = input('sales', DictType(SaleKeyType, IntegerType));
278
- const skus = input('skus', SetType(StringType));
279
- const rows = input('rows', ArrayType(IntegerType));
280
- assert.throws(() => partitionTask('bad', {
281
- partitions: [scalar],
282
- output: IntegerType,
283
- }, ($, s) => $.return(s)), /must be a collection \(Array, Set or Dict\)/);
284
- assert.throws(() => partitionTask('mixed', {
285
- partitions: [sales, skus],
286
- output: DictType(SaleKeyType, IntegerType),
287
- }, ($, a, _b) => $.return(a)), /co-partitioning is restricted to Dict\/Set roots/);
288
- assert.throws(() => partitionTask('arrays', {
289
- partitions: [rows, rows],
290
- output: ArrayType(IntegerType),
291
- }, ($, a, _b) => $.return(a)), /co-partitioning is restricted to Dict\/Set roots/);
292
- });
293
- it('co-partitions same-keyed dicts and types by over the shared key', () => {
294
- const a = input('a', DictType(SaleKeyType, IntegerType));
295
- const b = input('b', DictType(SaleKeyType, FloatType));
296
- const reconcile = partitionTask('reconcile', {
297
- partitions: [a, b],
298
- by: (_$, key) => key.sku,
299
- output: DictType(SaleKeyType, FloatType),
300
- }, ($, _sliceA, sliceB) => $.return(sliceB));
301
- assert.strictEqual(reconcile.taskKind, TASK_KIND_PARTITION);
302
- assert.strictEqual(decodePartitionTaskMetadata(reconcile.metadata).partitions, 2n);
303
- // function_ir + 2 partitions
304
- assert.strictEqual(reconcile.inputs.length, 3);
305
- });
306
- describe('heterogeneous co-partition keys (the implicit projection)', () => {
307
- // Same fields, opposite declared order — the field-wise intersection is
308
- // non-empty, but the datasets SORT differently, so implicit boundary
309
- // alignment would silently mis-assign rows at run time.
310
- const ReversedKeyType = StructType({ period: IntegerType, sku: StringType });
311
- it('rejects reordered key fields when no `by` is given', () => {
312
- const a = input('h_a', DictType(SaleKeyType, IntegerType));
313
- const b = input('h_b', DictType(ReversedKeyType, IntegerType));
314
- assert.throws(() => partitionTask('implicit_misorder', {
315
- partitions: [a, b],
316
- output: DictType(SaleKeyType, IntegerType),
317
- }, ($, sliceA, _sliceB) => $.return(sliceA)), /with no `by`, co-partition boundaries align on partitioned dataset 'h_a' key order \(sku, period\), which is not a leading prefix of partitioned dataset 'h_b' key field order \(period, sku\)/);
318
- });
319
- it('rejects reordered key fields under an identity `by` too', () => {
320
- const a = input('h_c', DictType(SaleKeyType, IntegerType));
321
- const b = input('h_d', DictType(ReversedKeyType, IntegerType));
322
- assert.throws(() => partitionTask('identity_misorder', {
323
- partitions: [a, b],
324
- by: (_$, key) => key,
325
- output: DictType(SaleKeyType, IntegerType),
326
- }, ($, sliceA, _sliceB) => $.return(sliceA)), /the identity `by` projection reads the shared key fields \(sku, period\), which is not a leading prefix of partitioned dataset 'h_d' key field order \(period, sku\)/);
327
- });
328
- it('rejects a same-named leading field whose types differ across datasets', () => {
329
- // `sku` exists in both keys at position 0 but with different types, so
330
- // the intersection drops it — the primary's comparator would compare
331
- // string skus against integer skus by kind rank.
332
- const IntSkuKeyType = StructType({ sku: IntegerType, period: IntegerType });
333
- const a = input('h_e', DictType(SaleKeyType, IntegerType));
334
- const b = input('h_f', DictType(IntSkuKeyType, IntegerType));
335
- assert.throws(() => partitionTask('type_mismatch', {
336
- partitions: [a, b],
337
- output: DictType(SaleKeyType, IntegerType),
338
- }, ($, sliceA, _sliceB) => $.return(sliceA)), /with no `by`, co-partition boundaries align on partitioned dataset 'h_e' key order/);
339
- });
340
- it('accepts a secondary whose key extends the primary key with trailing fields', () => {
341
- // The primary's full key IS a leading prefix of the secondary's — the
342
- // implicit alignment (projecting onto the primary's fields) is
343
- // monotone for both, so this stays legal without `by`.
344
- const ExtendedKeyType = StructType({ sku: StringType, period: IntegerType, line: IntegerType });
345
- const a = input('h_g', DictType(SaleKeyType, IntegerType));
346
- const b = input('h_h', DictType(ExtendedKeyType, IntegerType));
347
- const extended = partitionTask('extended_ok', {
348
- partitions: [a, b],
349
- output: DictType(SaleKeyType, IntegerType),
350
- }, ($, sliceA, _sliceB) => $.return(sliceA));
351
- assert.strictEqual(extended.taskKind, TASK_KIND_PARTITION);
352
- });
353
- });
354
- it('rejects a non-collection output in splice mode, and accepts it with combine', () => {
355
- const sales = input('splice_sales', DictType(SaleKeyType, IntegerType));
356
- const TotalType = StructType({ total: IntegerType });
357
- // Without combine, partitions return SHARDS that splice — a struct
358
- // cannot; this used to surface only at run time, on the first input
359
- // large enough to carve into two partitions.
360
- assert.throws(() => partitionTask('struct_splice', {
361
- partitions: [sales],
362
- output: TotalType,
363
- }, ($, slice) => $.return({ total: slice.size() })), /without `combine`, each partition returns a shard of the output and the shards splice \(or, with `merge`, merge\) in key order — the output must be a collection \(Array, Set or Dict\), got Struct/);
364
- const folded = partitionTask('struct_combine', {
365
- partitions: [sales],
366
- output: TotalType,
367
- combine: ($, a, b) => $.return({ total: a.total.add(b.total) }),
368
- }, ($, slice) => $.return({ total: slice.size() }));
369
- assert.strictEqual(folded.taskKind, TASK_KIND_PARTITION);
370
- });
371
116
  });
372
- describe('partitionTask merge', () => {
373
- const RowType = StructType({ id: IntegerType, name: StringType });
374
- const events = input('merge_events', DictType(IntegerType, RowType));
375
- it('encodes a Dict merge function as IR, and the Set union as a flag', () => {
376
- const byId = partitionTask('merge_by_id', {
377
- partitions: [events],
378
- output: DictType(IntegerType, RowType),
379
- merge: ($, _key, a, _b) => $.return(a),
380
- }, ($, slice) => $.return(slice));
381
- const dictMeta = decodePartitionTaskMetadata(byId.metadata);
382
- assert.strictEqual(dictMeta.merge.type, 'some');
383
- assert.strictEqual(dictMeta.mergeSets, false);
384
- assert.strictEqual(dictMeta.combine.type, 'none');
385
- // The bundle is the (Key, Value, Value) -> Value the orchestrator compiles.
386
- const bundle = decodeEastIR(dictMeta.merge.type === 'some' ? dictMeta.merge.value : new Uint8Array());
387
- const resolve = bundle.compile([]);
388
- const left = { id: 1n, name: 'left' };
389
- assert.deepStrictEqual(resolve(1n, left, { id: 1n, name: 'right' }), left);
390
- const ids = partitionTask('merge_ids', {
391
- partitions: [events],
392
- output: SetType(IntegerType),
393
- merge: 'union',
394
- }, ($, slice) => $.return(slice.keys()));
395
- const setMeta = decodePartitionTaskMetadata(ids.metadata);
396
- assert.strictEqual(setMeta.merge.type, 'none');
397
- assert.strictEqual(setMeta.mergeSets, true);
398
- });
399
- it('carries the merge command of the task\'s runner, built at export, and none without merge', () => {
400
- // The orchestrator's merge units run this command IR as an ordinary
401
- // execution: `<runner> merge --merge <merge IR> --range <range> -i
402
- // <partial>... -o <out>` for a Dict output, `--union` for a Set — the
403
- // key range is the input after the merge IR, then the partials.
404
- const byId = partitionTask('merge_cmd_by_id', {
405
- partitions: [events],
406
- output: DictType(IntegerType, RowType),
407
- merge: ($, _key, a, _b) => $.return(a),
408
- runner: { runtime: 'east-c', platforms: ['east-c-std'] },
409
- }, ($, slice) => $.return(slice));
410
- const dictMeta = decodePartitionTaskMetadata(byId.metadata);
411
- assert.strictEqual(dictMeta.mergeCommand.type, 'some');
412
- const dictCommand = decodeEastIR(dictMeta.mergeCommand.type === 'some' ? dictMeta.mergeCommand.value : new Uint8Array());
413
- assert.deepStrictEqual(dictCommand.compile([])(['merge.beast2', 'range.beast2', 'p0.beast2', 'p1.beast2'], 'out.beast2'), ['east-c', 'merge', '-p', 'east-c-std', '--merge', 'merge.beast2', '--range', 'range.beast2', '-i', 'p0.beast2', '-i', 'p1.beast2', '-o', 'out.beast2']);
414
- const ids = partitionTask('merge_cmd_ids', {
415
- partitions: [events],
416
- output: SetType(IntegerType),
417
- merge: 'union',
418
- }, ($, slice) => $.return(slice.keys()));
419
- const setMeta = decodePartitionTaskMetadata(ids.metadata);
420
- const setCommand = decodeEastIR(setMeta.mergeCommand.type === 'some' ? setMeta.mergeCommand.value : new Uint8Array());
421
- assert.deepStrictEqual(setCommand.compile([])(['range.beast2', 'p0.beast2', 'p1.beast2'], 'out.beast2'), ['east-node', 'merge', '-p', '@elaraai/east-node-std', '--union', '--range', 'range.beast2', '-i', 'p0.beast2', '-i', 'p1.beast2', '-o', 'out.beast2']);
422
- const spliced = partitionTask('merge_cmd_none', {
423
- partitions: [events],
424
- output: DictType(IntegerType, RowType),
425
- }, ($, slice) => $.return(slice));
426
- assert.strictEqual(decodePartitionTaskMetadata(spliced.metadata).mergeCommand.type, 'none');
117
+ describe('customTask', () => {
118
+ it('carries its command as the body, with no runner', () => {
119
+ const name_input = input('name', StringType, variant('value', 'World'));
120
+ const echo = customTask('echo', [name_input], StringType, (_$, inputs, out) => East.str `cp ${inputs.get(0n)} ${out}`);
121
+ assert.strictEqual(echo.runner, undefined);
122
+ assert.deepStrictEqual(echo.inputs, [name_input]);
123
+ assert.strictEqual(echo.body.kind, 'command');
124
+ if (echo.body.kind !== 'command')
125
+ return;
126
+ assert.deepStrictEqual(echo.body.command.compile([])(['in.beast2'], 'out.beast2'), ['bash', '-c', 'cp in.beast2 out.beast2']);
427
127
  });
428
- it('refuses merge alongside combine, on the wrong output kind, or in the wrong form', () => {
429
- assert.throws(() => partitionTask('merge_and_combine', {
430
- partitions: [events],
431
- output: DictType(IntegerType, RowType),
432
- merge: ($, _k, a, _b) => $.return(a),
433
- combine: ($, a, _b) => $.return(a),
434
- }, ($, slice) => $.return(slice)), /partitionTask 'merge_and_combine': `merge` and `combine` are two assembly modes — give one/);
435
- // The next three are the shapes the static types already forbid; a caller
436
- // outside TypeScript's reach (a JS author, an `any`) still gets a message
437
- // naming the task, so they go through an untyped alias.
438
- const untypedPartitionTask = partitionTask;
439
- const takeLeft = (_$, _k, a) => a;
440
- assert.throws(() => untypedPartitionTask('merge_array', { partitions: [events], output: ArrayType(RowType), merge: takeLeft }, () => { }), /partitionTask 'merge_array': a `merge` FUNCTION resolves a key present in two partials, so the output must be a Dict, got Array/);
441
- assert.throws(() => untypedPartitionTask('merge_fn_on_set', { partitions: [events], output: SetType(IntegerType), merge: takeLeft }, () => { }), /got Set — a Set output takes `merge: 'union'`/);
442
- assert.throws(() => untypedPartitionTask('merge_union_on_dict', { partitions: [events], output: DictType(IntegerType, RowType), merge: 'union' }, () => { }), /`merge: 'union'` assembles a Set output, got Dict — a Dict output takes a per-key merge function/);
128
+ });
129
+ describe('e3.output', () => {
130
+ it('types each kind\'s output and emit', () => {
131
+ const array = output.array(FloatType);
132
+ assert.ok(sameType(array.type, ArrayType(FloatType)));
133
+ assert.ok(sameType(array.emit, FunctionType([FloatType], NullType)));
134
+ const set = output.set(StringType);
135
+ assert.ok(sameType(set.type, SetType(StringType)));
136
+ assert.ok(sameType(set.emit, FunctionType([StringType], NullType)));
137
+ const dict = output.dict(StringType, IntegerType);
138
+ assert.ok(sameType(dict.type, DictType(StringType, IntegerType)));
139
+ assert.ok(sameType(dict.emit, FunctionType([StringType, IntegerType], NullType)));
140
+ assert.strictEqual(dict.merge, undefined);
141
+ // A fold's output is the folded value, whatever its type.
142
+ const fold = output.fold(ArrayType(IntegerType), { zero: [], combine: ($, a, b) => a.concat(b) });
143
+ assert.ok(sameType(fold.type, ArrayType(IntegerType)));
144
+ assert.ok(sameType(fold.emit, FunctionType([ArrayType(IntegerType)], NullType)));
443
145
  });
444
- it('types merge over the output\'s own key and value, not the partition key', () => {
445
- const SaleKeyType = StructType({ sku: StringType, period: IntegerType });
446
- const sales = input('merge_sales', DictType(SaleKeyType, IntegerType));
447
- // Re-keyed by sku: the merge sees the OUTPUT's String key and Integer
448
- // values (a value-level `add` would not type-check against the whole
449
- // output), and its IR is the (String, Integer, Integer) -> Integer fold.
450
- const bySku = partitionTask('merge_by_sku', {
451
- partitions: [sales],
452
- output: DictType(StringType, IntegerType),
453
- merge: (_$, _sku, a, b) => a.add(b),
454
- }, ($, slice) => slice.toArray(($, v, k) => ({ sku: k.sku, v }))
455
- .groupReduce(($, x) => x.sku, ($, _k) => 0n, ($, acc, x) => acc.add(x.v)));
456
- const meta = decodePartitionTaskMetadata(bySku.metadata);
457
- const merge = decodeEastIR(meta.merge.type === 'some' ? meta.merge.value : new Uint8Array());
458
- assert.strictEqual(merge.ir.value.parameters.length, 3);
459
- assert.strictEqual(merge.compile([])('x', 2n, 3n), 5n);
146
+ it('builds a dict\'s merge over the key and two values, and a fold\'s combine over two values', () => {
147
+ const dict = output.dict(StringType, IntegerType, { merge: (_$, _key, a, b) => a.add(b) });
148
+ assert.strictEqual(dict.merge.compile([])('x', 2n, 3n), 5n);
149
+ const fold = output.fold(IntegerType, { zero: 0n, combine: (_$, a, b) => a.add(b) });
150
+ assert.strictEqual(fold.zero, 0n);
151
+ assert.strictEqual(fold.combine.compile([])(2n, 3n), 5n);
460
152
  });
461
- it('refuses merge on the custom runtime, which cannot run the merge units', () => {
462
- assert.throws(() => partitionTask('merge_custom', {
463
- partitions: [events],
464
- output: DictType(IntegerType, RowType),
465
- merge: ($, _key, a, _b) => $.return(a),
466
- runner: { runtime: 'custom', command: ['my-runner'] },
467
- }, ($, slice) => $.return(slice)), /^Error: partitionTask 'merge_custom': merge runs the fan-in on the task's runner, which must be a stock runtime \(east-node, east-py, east-c\) — the custom runtime has no merge command$/);
153
+ it('refuses a fold whose zero is not a value of its type', () => {
154
+ assert.throws(() => output.fold(IntegerType, { zero: 'none', combine: (_$, a, b) => a.add(b) }), /^Error: e3\.output\.fold: zero is not a value of \.Integer$/);
468
155
  });
469
156
  });
470
157
  describe('streamTask', () => {
471
158
  const EventType = StructType({ at: IntegerType, amount: FloatType });
472
- it('builds a stream-kind task with emit as the trailing body parameter', () => {
159
+ it('passes the inputs, then emit, to a body whose output kind types the task', () => {
473
160
  const events = input('events', ArrayType(EventType));
474
161
  const balances = streamTask('balances', {
475
- stream: events,
476
- output: ArrayType(FloatType),
162
+ inputs: [events],
163
+ output: output.array(FloatType),
477
164
  }, ($, events, emit) => {
478
165
  const balance = $.let(0.0);
479
166
  $.for(events, ($, event) => {
@@ -482,119 +169,117 @@ describe('streamTask', () => {
482
169
  });
483
170
  });
484
171
  assert.strictEqual(balances.kind, 'task');
485
- assert.strictEqual(balances.taskKind, TASK_KIND_STREAM);
486
- // function_ir + stream input; emit is a body parameter, not a dataset
487
- assert.strictEqual(balances.inputs.length, 2);
488
- assert.strictEqual(balances.inputs[0].name, 'function_ir');
489
- assert.strictEqual(balances.inputs[1], events);
490
- const meta = decodeStreamTaskMetadata(balances.metadata);
491
- assert.strictEqual(meta.stream, true);
492
- assert.strictEqual(meta.emit, 'array');
493
- assert.strictEqual(meta.merge, 'none');
172
+ assert.deepStrictEqual(balances.inputs, [events]);
173
+ assert.strictEqual(balances.outputKind?.kind, 'array');
174
+ assert.ok(sameType(balances.output.type, ArrayType(FloatType)));
175
+ assert.deepStrictEqual(balances.role, variant('data', null));
176
+ // The body's parameters are the inputs and then emit, which the runner
177
+ // passes: here, a sink recording what is emitted.
178
+ assert.strictEqual(balances.body.kind, 'east');
179
+ if (balances.body.kind !== 'east')
180
+ return;
181
+ const emitted = [];
182
+ const run = balances.body.program.compile([]);
183
+ run([{ at: 1n, amount: 2.5 }, { at: 2n, amount: 1.0 }], (x) => { emitted.push(x); return null; });
184
+ assert.deepStrictEqual(emitted, [2.5, 3.5]);
494
185
  });
495
- it('supports producer mode (no stream input) and dict emit', () => {
496
- const limit = input('limit', IntegerType, variant('value', 3n));
186
+ it('is a producer with no inputs, emitting keys and values into a dict', () => {
497
187
  const producer = streamTask('ingest', {
498
- inputs: [limit],
499
- output: DictType(IntegerType, StringType),
500
- }, ($, limit, emit) => {
501
- const source = $.const([0n, 1n, 2n, 3n, 4n], ArrayType(IntegerType));
188
+ inputs: [],
189
+ output: output.dict(IntegerType, StringType),
190
+ }, ($, emit) => {
191
+ const source = $.const([0n, 1n, 2n], ArrayType(IntegerType));
502
192
  $.for(source, ($, k) => {
503
- $.if(East.less(k, limit), ($) => {
504
- $(emit(k, East.str `row-${k}`));
505
- });
193
+ $(emit(k, East.str `row-${k}`));
506
194
  });
507
195
  });
508
- assert.strictEqual(producer.taskKind, TASK_KIND_STREAM);
509
- // function_ir + 1 ordinary input
510
- assert.strictEqual(producer.inputs.length, 2);
511
- const meta = decodeStreamTaskMetadata(producer.metadata);
512
- assert.strictEqual(meta.stream, false);
513
- assert.strictEqual(meta.emit, 'dict');
196
+ assert.deepStrictEqual(producer.inputs, []);
197
+ assert.strictEqual(producer.outputKind?.kind, 'dict');
198
+ if (producer.body.kind !== 'east')
199
+ return assert.fail('a stream task runs a program');
200
+ const emitted = [];
201
+ producer.body.program.compile([])((k, v) => { emitted.push([k, v]); return null; });
202
+ assert.deepStrictEqual(emitted, [[0n, 'row-0'], [1n, 'row-1'], [2n, 'row-2']]);
514
203
  });
515
- it('rejects non-collection outputs and the custom runtime', () => {
204
+ it('refuses the custom runtime, and an output that is not an output kind', () => {
516
205
  const events = input('events', ArrayType(EventType));
517
- assert.throws(() => streamTask('scalar_out', {
518
- stream: events,
519
- output: FloatType,
520
- }, () => { }), /output must be a collection type/);
521
206
  assert.throws(() => streamTask('custom_stream', {
522
- stream: events,
523
- output: ArrayType(FloatType),
207
+ inputs: [events],
208
+ output: output.array(FloatType),
524
209
  runner: { runtime: 'custom', command: ['my-runner'] },
525
- }, () => { }), /custom runtime cannot carry the streaming flags/);
526
- // Stock non-node runtimes are accepted — they stream the output too.
527
- const cStream = streamTask('c_stream', {
528
- stream: events,
529
- output: ArrayType(FloatType),
530
- runner: { runtime: 'east-c', platforms: ['east-c-std'] },
531
- }, ($, events, emit) => {
532
- $.for(events, ($, event) => {
533
- $(emit(event.amount));
534
- });
535
- });
536
- assert.strictEqual(cStream.taskKind, TASK_KIND_STREAM);
210
+ }, () => { }), /^Error: streamTask 'custom_stream': the custom runtime runs only a program that returns its output — use a stock runtime \(east-node, east-py, east-c\)$/);
211
+ // The static types forbid a bare East type; a caller outside TypeScript's
212
+ // reach still gets a message naming the task.
213
+ const untypedStreamTask = streamTask;
214
+ assert.throws(() => untypedStreamTask('scalar_out', { inputs: [events], output: FloatType }, () => { }), /^Error: streamTask 'scalar_out': output is an output kind — e3\.output\.array, set, dict or fold$/);
537
215
  });
538
- describe('merge', () => {
539
- const TotalsType = DictType(StringType, FloatType);
540
- it('folds equal Dict keys with a merge function staged as wire input 1', () => {
541
- const events = input('merge_stream_events', ArrayType(StructType({ account: StringType, amount: FloatType })));
542
- const rates = input('merge_stream_rates', FloatType, variant('value', 1.0));
543
- const totals = streamTask('totals', {
544
- stream: events,
545
- inputs: [rates],
546
- output: TotalsType,
547
- merge: (_$, _account, a, b) => a.add(b),
548
- runner: { runtime: 'east-c', platforms: ['east-c-std'] },
549
- }, ($, events, rate, emit) => {
550
- $.for(events, ($, event) => {
551
- $(emit(event.account, event.amount.multiply(rate)));
552
- });
553
- });
554
- // function_ir, merge_ir, the stream, then the ordinary input.
555
- assert.deepStrictEqual(totals.inputs.map((d) => d.name), ['function_ir', 'merge_ir', 'merge_stream_events', 'merge_stream_rates']);
556
- const mergeIR = totals.inputs[1];
557
- assert.deepStrictEqual(mergeIR.path, [variant('field', 'tasks'), variant('field', 'totals'), variant('field', 'merge_ir')]);
558
- assert.strictEqual(mergeIR.writable, false);
559
- const fold = mergeIR.default.compile([]);
560
- assert.strictEqual(fold('x', 1.5, 2.0), 3.5);
561
- const meta = decodeStreamTaskMetadata(totals.metadata);
562
- assert.deepStrictEqual(meta, { stream: true, emit: 'dict', merge: 'function' });
563
- // The runner receives the merge IR as --merge and streams the first -i input.
564
- const argv = totals.command.compile([])(['body.beast2', 'merge.beast2', 'events.beast2', 'rates.beast2'], 'out.beast2');
565
- assert.deepStrictEqual(argv, [
566
- 'east-c', 'run', '-p', 'east-c-std', '--emit', 'dict', '--merge', 'merge.beast2', '--stream', '0',
567
- '-i', 'events.beast2', '-i', 'rates.beast2', '-o', 'out.beast2', 'body.beast2',
568
- ]);
569
- });
570
- it('collapses equal Set elements with union, which stages no input', () => {
571
- const events = input('union_stream_events', ArrayType(StringType));
572
- const accounts = streamTask('accounts', {
573
- stream: events,
574
- output: SetType(StringType),
575
- merge: 'union',
576
- }, ($, events, emit) => {
577
- $.for(events, ($, account) => {
578
- $(emit(account));
579
- });
216
+ });
217
+ describe('e3.partition', () => {
218
+ const SaleKeyType = StructType({ sku: StringType, period: IntegerType });
219
+ const NestedKeyType = StructType({ head: StructType({ region: StringType, store: IntegerType }), seq: IntegerType });
220
+ it('marks a stream task\'s input, which its body receives typed as the whole dataset', () => {
221
+ const sales = input('sales', DictType(SaleKeyType, IntegerType));
222
+ const rates = input('rates', FloatType, variant('value', 1.0));
223
+ const bySku = partition(sales, { by: ['sku'] });
224
+ const totals = streamTask('totals', {
225
+ inputs: [bySku, rates],
226
+ output: output.dict(StringType, FloatType, { merge: (_$, _sku, a, b) => a.add(b) }),
227
+ }, ($, sales, rate, emit) => {
228
+ $.for(sales, ($, qty, key) => {
229
+ $(emit(key.sku, qty.toFloat().multiply(rate)));
580
230
  });
581
- assert.deepStrictEqual(accounts.inputs.map((d) => d.name), ['function_ir', 'union_stream_events']);
582
- assert.deepStrictEqual(decodeStreamTaskMetadata(accounts.metadata), { stream: true, emit: 'set', merge: 'union' });
583
- const argv = accounts.command.compile([])(['body.beast2', 'events.beast2'], 'out.beast2');
584
- assert.deepStrictEqual(argv, [
585
- 'east-node', 'run', '-p', '@elaraai/east-node-std', '--emit', 'set', '--union', '--stream', '0',
586
- '-i', 'events.beast2', '-o', 'out.beast2', 'body.beast2',
587
- ]);
588
- });
589
- it('refuses merge for an Array output and in the wrong form for the output kind', () => {
590
- // The static types already forbid both; a caller outside TypeScript's
591
- // reach still gets a message naming the task.
592
- const untypedStreamTask = streamTask;
593
- const takeLeft = (_$, _k, a) => a;
594
- assert.throws(() => untypedStreamTask('merge_array_out', { output: ArrayType(FloatType), merge: takeLeft }, () => { }), /^Error: streamTask 'merge_array_out': merge applies to Dict \(a function\) or Set \('union'\) outputs, got Array$/);
595
- assert.throws(() => untypedStreamTask('union_on_dict', { output: TotalsType, merge: 'union' }, () => { }), /streamTask 'union_on_dict': merge applies to Dict \(a function\) or Set \('union'\) outputs, got Dict with 'union'/);
596
- assert.throws(() => untypedStreamTask('fn_on_set', { output: SetType(StringType), merge: takeLeft }, () => { }), /streamTask 'fn_on_set': merge applies to Dict \(a function\) or Set \('union'\) outputs, got Set with a function/);
597
231
  });
232
+ assert.deepStrictEqual(bySku, { kind: 'partition', dataset: sales, by: ['sku'] });
233
+ assert.deepStrictEqual(totals.inputs, [bySku, rates]);
234
+ // The task depends on the dataset under the mark.
235
+ assert.ok(totals.deps.has(sales));
236
+ assert.ok(totals.deps.has(rates));
237
+ });
238
+ it('accepts leading key fields, the last of which may read first fields into a struct', () => {
239
+ const sales = input('sales', DictType(SaleKeyType, IntegerType));
240
+ const nested = input('nested', DictType(NestedKeyType, IntegerType));
241
+ const body = () => { };
242
+ for (const by of [[], ['sku'], ['sku', 'period']]) {
243
+ streamTask('ok', { inputs: [partition(sales, { by })], output: output.set(StringType) }, body);
244
+ }
245
+ for (const by of [['head'], ['head.region'], ['head', 'seq']]) {
246
+ streamTask('ok', { inputs: [partition(nested, { by })], output: output.set(StringType) }, body);
247
+ }
248
+ });
249
+ it('refuses a by that is not leading key fields, naming the task, the input and the key\'s fields', () => {
250
+ const sales = input('sales', DictType(SaleKeyType, IntegerType));
251
+ const nested = input('nested', DictType(NestedKeyType, IntegerType));
252
+ const body = () => { };
253
+ assert.throws(() => streamTask('bad_by', { inputs: [partition(sales, { by: ['period'] })], output: output.set(StringType) }, body), /^Error: streamTask 'bad_by': partitioned input 'sales': `by` \(period\) must name leading key fields in order — the key's fields are \(sku, period\), and only the last entry may read into one, as 'at\.day' does$/);
254
+ assert.throws(() => streamTask('path_first', { inputs: [partition(nested, { by: ['head.region', 'seq'] })], output: output.set(StringType) }, body), /`by` \(head\.region, seq\) must name leading key fields in order/);
255
+ assert.throws(() => streamTask('path_step', { inputs: [partition(nested, { by: ['head.store'] })], output: output.set(StringType) }, body), /^Error: streamTask 'path_step': partitioned input 'nested': `by` path 'head\.store' reads 'store', which is not the first field of \(region, store\) — rows sort by a struct's first field, so only it groups them$/);
256
+ });
257
+ it('refuses a partitioned input that is not a collection, or a by on an input with no key fields', () => {
258
+ const scalar = input('scalar', IntegerType, variant('value', 1n));
259
+ const rows = input('rows', ArrayType(IntegerType));
260
+ const skus = input('skus', SetType(StringType));
261
+ const body = () => { };
262
+ assert.throws(() => streamTask('scalar', { inputs: [partition(scalar)], output: output.set(StringType) }, body), /^Error: streamTask 'scalar': partitioned input 'scalar' must be a collection \(Array, Set or Dict\), got Integer$/);
263
+ assert.throws(() => streamTask('array_by', { inputs: [partition(rows, { by: ['x'] })], output: output.set(StringType) }, body), /partitioned input 'rows' is an Array, cut by position, so it has no key for `by` to name/);
264
+ assert.throws(() => streamTask('string_by', { inputs: [partition(skus, { by: ['x'] })], output: output.set(StringType) }, body), /partitioned input 'skus' has a String key, which has no fields for `by` to name/);
265
+ // An Array cut by position, with no `by`, is a partitioned input.
266
+ streamTask('array', { inputs: [partition(rows)], output: output.array(IntegerType) }, body);
267
+ });
268
+ it('co-partitions inputs cut by keys, or by fields, of the same types — and nothing else', () => {
269
+ const a = input('a', DictType(SaleKeyType, IntegerType));
270
+ const b = input('b', DictType(SaleKeyType, FloatType));
271
+ const bySkuOnly = input('by_sku', DictType(StringType, FloatType));
272
+ const lines = input('lines', DictType(StructType({ sku: StringType, line: IntegerType }), IntegerType));
273
+ const reversed = input('reversed', DictType(StructType({ period: IntegerType, sku: StringType }), IntegerType));
274
+ const rows = input('rows', ArrayType(IntegerType));
275
+ const body = () => { };
276
+ // Identical keys; a key against a `by` field of its type; two `by`s over
277
+ // different keys.
278
+ streamTask('same_keys', { inputs: [partition(a), partition(b)], output: output.set(StringType) }, body);
279
+ streamTask('key_and_by', { inputs: [partition(bySkuOnly), partition(a, { by: ['sku'] })], output: output.set(StringType) }, body);
280
+ streamTask('two_bys', { inputs: [partition(a, { by: ['sku'] }), partition(lines, { by: ['sku'] })], output: output.set(StringType) }, body);
281
+ assert.throws(() => streamTask('reordered', { inputs: [partition(a), partition(reversed)], output: output.set(StringType) }, body), /^Error: streamTask 'reordered': co-partitioned inputs 'a' and 'reversed' have no common key — they are cut by \(\.Struct \[\(name="sku", type=\.String\), \(name="period", type=\.Integer\)\]\) and \(\.Struct \[\(name="period", type=\.Integer\), \(name="sku", type=\.String\)\]\); give each a `by` naming fields of the same types$/);
282
+ assert.throws(() => streamTask('with_array', { inputs: [partition(a), partition(rows)], output: output.set(StringType) }, body), /^Error: streamTask 'with_array': co-partitioned inputs are cut at the same keys, so each must be a Set or a Dict — partition one input and pass the others whole$/);
598
283
  });
599
284
  });
600
285
  //# sourceMappingURL=task.spec.js.map