@elaraai/e3 1.0.79 → 1.0.80

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 +4 -3
  86. package/dist/src/sha256.d.ts.map +1 -1
  87. package/dist/src/sha256.js +20 -17
  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
package/dist/src/task.js CHANGED
@@ -2,9 +2,8 @@
2
2
  * Copyright (c) 2025 Elara AI Pty Ltd
3
3
  * Dual-licensed under AGPL-3.0 and commercial license. See LICENSE for details.
4
4
  */
5
- import { Expr, variant, some, none, ArrayType, StringType, NullType, FunctionType, StructType, East, IRType, encodeEastIR, toEastTypeValue, isTypeValueEqual } from '@elaraai/east';
6
- import { TASK_KIND_PARTITION, TASK_KIND_STREAM, encodePartitionTaskMetadata, encodeStreamTaskMetadata, mergeCommandIr, partitionProjectionShape, streamCommandIr } from '@elaraai/e3-types';
7
- import { DEFAULT_RUNNER, runnerToCommand, runnerToVariant } from './runner.js';
5
+ import { Expr, variant, ArrayType, StringType, NullType, East, isTypeValueEqual, printType, toEastTypeValue } from '@elaraai/east';
6
+ import { DEFAULT_RUNNER } from './runner.js';
8
7
  import { validateEnvironmentDecl } from './environment.js';
9
8
  /**
10
9
  * Singleton tree definition for `.tasks`.
@@ -31,29 +30,6 @@ function createTaskTree(name) {
31
30
  deps: new Set([tasksTree]),
32
31
  };
33
32
  }
34
- /**
35
- * Creates a private IR dataset for a task at `.tasks.${name}.${field}`: the
36
- * task's `function_ir`, or a stream task's `merge_ir`.
37
- *
38
- * @param name - Task name
39
- * @param taskTree - The task's subtree
40
- * @param field - The dataset's name within the task's subtree
41
- * @param eastIR - The compiled function IR
42
- * @returns A DatasetDef for the IR (private, not typed)
43
- */
44
- function createIRDataset(name, taskTree, field, eastIR) {
45
- return {
46
- kind: 'dataset',
47
- name: field,
48
- path: [variant('field', 'tasks'), variant('field', name), variant('field', field)],
49
- type: IRType,
50
- // Store the full EastIR/AsyncEastIR bundle so export.ts can use encodeEastIR
51
- // and preserve source_map into the beast2 blob.
52
- default: eastIR,
53
- writable: false,
54
- deps: new Set([...taskTree.deps, taskTree]),
55
- };
56
- }
57
33
  /**
58
34
  * Creates an output dataset for a task at `.tasks.${name}.output`.
59
35
  *
@@ -76,7 +52,7 @@ function createOutputDataset(name, taskTree, outputType) {
76
52
  * Collects all dependencies for a task.
77
53
  *
78
54
  * Walks the dependency graph to include:
79
- * - The task's subtree and its contents (function_ir, output)
55
+ * - The task's subtree and its output
80
56
  * - All input datasets and their dependencies
81
57
  */
82
58
  function collectDeps(taskTree, outputDataset, inputs) {
@@ -99,54 +75,24 @@ function collectDeps(taskTree, outputDataset, inputs) {
99
75
  export function task(name, inputs, fn, config) {
100
76
  if (config?.environment)
101
77
  validateEnvironmentDecl(config.environment, name);
102
- // Keep the full EastIR bundle (IR + source_map) so we don't drop the
103
- // source map before it reaches the beast2 encoder in export.ts.
104
- const eastIR = fn.toIR();
105
- const outputType = Expr.type(fn).output;
106
- // Create the task's subtree at .tasks.${name}
78
+ for (const input of inputs) {
79
+ if (input.kind === 'partition') {
80
+ throw new Error(`task '${name}': input '${input.dataset.name}' is marked with e3.partition, which only e3.streamTask takes — ` +
81
+ `pass the dataset itself`);
82
+ }
83
+ }
107
84
  const taskTree = createTaskTree(name);
108
- // Create the function_ir dataset (private, holds the IR bundle)
109
- const functionIRDataset = createIRDataset(name, taskTree, 'function_ir', eastIR);
110
- // The first input is the FunctionIR to execute
111
- const input_datasets = [
112
- functionIRDataset,
113
- ...inputs
114
- ];
115
- // Create the output dataset
116
- const output = createOutputDataset(name, taskTree, outputType);
117
- // Resolve the typed Runner to argv at task-definition time. The variant is
118
- // SDK-only metadata: it never reaches the IR or the wire — only the
119
- // resolved string array does, baked in as an East constant below. Default
120
- // is east-node + east-node-std (every e3 project has Node already).
121
- const argvPrefix = runnerToCommand(config?.runner ?? DEFAULT_RUNNER);
122
- // Build the command IR. At task-execution time this East function is
123
- // evaluated with the staged input/output paths and returns the final argv.
124
- const commandFn = East.function([ArrayType(StringType), StringType], ArrayType(StringType), ($, input_paths, output_path) => {
125
- const command = $.let(argvPrefix, ArrayType(StringType));
126
- // Function argument paths
127
- const i = $.let(1n);
128
- $.while(East.less(i, input_paths.size()), $ => {
129
- $(command.pushLast("-i"));
130
- $(command.pushLast(input_paths.get(i)));
131
- $.assign(i, i.add(1n));
132
- });
133
- // Output path
134
- $(command.pushLast('-o'));
135
- $(command.pushLast(output_path));
136
- // Function IR is the first input
137
- $(command.pushLast(input_paths.get(0n)));
138
- $.return(command);
139
- });
85
+ const output = createOutputDataset(name, taskTree, Expr.type(fn).output);
140
86
  const taskDef = {
141
87
  kind: 'task',
142
88
  name,
143
- // Keep the full EastIR bundle so export.ts can encode with source map.
144
- command: commandFn.toIR(),
145
- inputs: input_datasets,
89
+ // Keep the full EastIR bundle (IR + source map) so export.ts can encode
90
+ // it with its source map.
91
+ body: { kind: 'east', program: fn.toIR() },
92
+ inputs,
146
93
  output,
147
- deps: collectDeps(taskTree, output, input_datasets),
148
- taskKind: config?.kind,
149
- metadata: config?.metadata,
94
+ role: config?.role ?? variant('data', null),
95
+ deps: collectDeps(taskTree, output, inputs),
150
96
  runner: config?.runner ?? DEFAULT_RUNNER,
151
97
  environment: config?.environment,
152
98
  };
@@ -154,6 +100,20 @@ export function task(name, inputs, fn, config) {
154
100
  output.deps.add(taskDef);
155
101
  return taskDef;
156
102
  }
103
+ /**
104
+ * Defines a task that runs a command rather than East: a bash script built
105
+ * from the staged input paths and the path the output must be written to.
106
+ *
107
+ * @typeParam Name - Task name (literal type)
108
+ * @typeParam Inputs - Input dataset types
109
+ * @typeParam Output - Output type
110
+ * @param name - Task name
111
+ * @param inputs - Input datasets, staged as beast2 files
112
+ * @param outputType - The East type of the beast2 file the command writes
113
+ * @param command - Builds the bash script from the input paths and the output path
114
+ * @param config - The environment the command runs in
115
+ * @returns A TaskDef with `.output` for chaining
116
+ */
157
117
  export function customTask(name, inputs, outputType, command, config) {
158
118
  if (config?.environment)
159
119
  validateEnvironmentDecl(config.environment, name);
@@ -169,9 +129,10 @@ export function customTask(name, inputs, outputType, command, config) {
169
129
  kind: 'task',
170
130
  name,
171
131
  // Keep the full EastIR bundle so export.ts can encode with source map.
172
- command: commandFn.toIR(),
132
+ body: { kind: 'command', command: commandFn.toIR() },
173
133
  inputs,
174
134
  output,
135
+ role: variant('data', null),
175
136
  deps: collectDeps(taskTree, output, inputs),
176
137
  environment: config?.environment,
177
138
  };
@@ -179,326 +140,156 @@ export function customTask(name, inputs, outputType, command, config) {
179
140
  output.deps.add(taskDef);
180
141
  return taskDef;
181
142
  }
182
- /** Default carved-slice byte target for {@link partitionTask}. */
183
- const DEFAULT_TARGET_PARTITION_BYTES = 256 * 1024 * 1024;
184
- /** The East key type a collection dataset is carved by. */
185
- function collectionKeyType(name, dataset) {
186
- const type = dataset.type;
187
- switch (type.type) {
188
- case 'Dict': return type.key;
189
- case 'Set': return type.key;
190
- case 'Array': return type.value;
191
- default:
192
- throw new Error(`partitionTask '${name}': partitioned dataset '${dataset.name}' must be a collection (Array, Set or Dict), got ${type.type}`);
193
- }
194
- }
143
+ // =============================================================================
144
+ // Streaming tasks
145
+ // =============================================================================
195
146
  /**
196
- * Defines a partitioned task: e3 carves the huge partitioned input(s) into
197
- * key-range slices, runs `fn` once per partition as an ordinary
198
- * content-addressed execution (parallel, memoized per partition), and
199
- * assembles the output — by splicing shards in partition order, by merging
200
- * keyed partials on the task's runner when `merge` is given, or by folding
201
- * partials pairwise when `combine` is given.
147
+ * Marks an input of a {@link streamTask} as one its work may be split over.
202
148
  *
203
- * `fn` always returns the output type: without `combine` each execution
204
- * returns its *shard* of the output (Array shards concatenate freely;
205
- * Dict/Set shard key ranges must ascend disjointly in partition order — any
206
- * key-preserving or monotone re-keying transform qualifies). The shard
207
- * contract is deliberately enforced at SPLICE TIME, not build time: whether
208
- * an arbitrary body preserves key order is not decidable from types, and a
209
- * static rule would false-reject permitted monotone re-keys — a violation
210
- * fails the task deterministically at splice, naming the offending
211
- * partitions and the remedies (`combine` / `customTask`). With `combine`
212
- * each execution returns a *partial* and the partials fold.
149
+ * The body receives one piece of the dataset, typed as the whole: a key range
150
+ * of a Set or Dict, or a position range of an Array. Pieces are cut where the
151
+ * content says, so an insertion re-cuts only the pieces around it. Rows whose
152
+ * `by` fields are equal are never split across pieces.
213
153
  *
214
- * Parameter order is fixed and fully typed: the partition slices first (in
215
- * declared order, each typed as its dataset's own collection type), then the
216
- * ordinary `inputs` in order.
154
+ * `by` names leading key fields, in order — `['account']`, or
155
+ * `['account', 'at.day']`, whose last entry reads the first field of `at`. Two
156
+ * or more partitioned inputs are cut at the same keys, so they must be Sets or
157
+ * Dicts whose keys, or whose `by` fields, have the same types. {@link
158
+ * streamTask} checks all of this against the dataset, naming the task.
217
159
  *
218
- * @typeParam Name - Task name (literal type)
219
- * @typeParam Partitions - Partitioned input dataset defs
220
- * @typeParam Inputs - Ordinary input dataset defs
221
- * @typeParam Output - Output type
222
- * @param name - Task name
223
- * @param spec - The declaration: partitioned inputs, alignment, output type,
224
- * combine mode, sizing, runner and environment
225
- * @param fn - Implementation body, run once per partition
226
- * @returns A TaskDef with `.output` for chaining
227
- * @throws {Error} When a partitioned dataset is not a collection, partitions
228
- * mix collection kinds (or include Arrays) under co-partitioning, key
229
- * types cannot align, `by` (explicit or the implicit alignment under
230
- * co-partitioning) does not read a leading prefix of every partitioned
231
- * dataset's key, the output is not a collection in splice mode (no
232
- * `combine`), or `merge` is given alongside `combine`, on an Array output,
233
- * in the wrong form for the output's kind, or with the `custom` runtime.
160
+ * @typeParam T - The dataset's East type
161
+ * @param dataset - The dataset
162
+ * @param config - `by`, the fields rows are grouped by
163
+ * @returns The marked input, to pass in a stream task's `inputs`
234
164
  *
235
165
  * @example
236
166
  * ```ts
237
- * const sales = e3.input('sales', DictType(StringType, SaleType));
238
- *
239
- * const cleaned = e3.partitionTask('cleaned', {
240
- * partitions: [sales],
241
- * output: DictType(StringType, SaleType),
242
- * }, ($, slice) => slice.filter(($, sale) => East.greater(sale.qty, 0n)));
167
+ * const totals = e3.streamTask('totals', {
168
+ * inputs: [e3.partition(sales, { by: ['account'] }), rates],
169
+ * output: e3.output.dict(StringType, FloatType, { merge: ($, account, a, b) => a.add(b) }),
170
+ * }, ($, sales, rates, emit) => {
171
+ * $.for(sales, ($, sale, key) => {
172
+ * $(emit(key.account, sale.amount.multiply(rates.get(sale.currency))));
173
+ * });
174
+ * });
243
175
  * ```
244
176
  */
245
- export function partitionTask(name, spec, fn) {
246
- const partitions = spec.partitions;
247
- const inputs = (spec.inputs ?? []);
248
- const output = spec.output;
249
- // Partitioned inputs must be collections; co-partitioning is restricted to
250
- // Dict/Set roots (a shared key space must exist for boundary alignment —
251
- // an Array has none).
252
- const keyTypes = partitions.map((p) => collectionKeyType(name, p));
253
- const kinds = partitions.map((p) => p.type.type);
254
- if (partitions.length > 1) {
255
- const first = kinds[0];
256
- if (first === 'Array' || kinds.some((k) => k !== first)) {
257
- throw new Error(`partitionTask '${name}': co-partitioning is restricted to Dict/Set roots sharing a key space — ` +
258
- `got [${kinds.join(', ')}]. Partition one dataset and access the others as ordinary inputs, or re-key upstream.`);
259
- }
260
- }
261
- // The `by` parameter type: a single partition's own key, or the strict
262
- // field-wise intersection of every key struct under co-partitioning.
263
- let byParamType;
264
- if (partitions.length === 1) {
265
- byParamType = keyTypes[0];
266
- }
267
- else if (keyTypes.every((k) => k.type === 'Struct')) {
268
- const first = keyTypes[0];
269
- const shared = {};
270
- for (const [field, fieldType] of Object.entries(first.fields)) {
271
- const everywhere = keyTypes.every((k) => {
272
- const other = k.fields[field];
273
- return other !== undefined && isTypeValueEqual(toEastTypeValue(other), toEastTypeValue(fieldType));
274
- });
275
- if (everywhere)
276
- shared[field] = fieldType;
177
+ export function partition(dataset, config) {
178
+ return { kind: 'partition', dataset, by: [...(config?.by ?? [])] };
179
+ }
180
+ /** The key a Set or Dict is cut by; an Array, cut by position, has none. */
181
+ function collectionKey(type) {
182
+ const collection = type;
183
+ return collection.type === 'Set' || collection.type === 'Dict' ? collection.key : undefined;
184
+ }
185
+ /**
186
+ * Checks a stream task's partitioned inputs against their datasets: each is a
187
+ * collection, each `by` names leading key fields, and co-partitioned inputs
188
+ * are Sets or Dicts cut by fields of the same types.
189
+ */
190
+ function checkPartitions(name, partitions) {
191
+ const cuts = [];
192
+ for (const { dataset, by } of partitions) {
193
+ const where = `streamTask '${name}': partitioned input '${dataset.name}'`;
194
+ const type = dataset.type;
195
+ if (type.type !== 'Array' && type.type !== 'Set' && type.type !== 'Dict') {
196
+ throw new Error(`${where} must be a collection (Array, Set or Dict), got ${type.type}`);
277
197
  }
278
- if (Object.keys(shared).length === 0) {
279
- throw new Error(`partitionTask '${name}': co-partitioned key types share no identically-typed fields — the datasets have no common key space`);
198
+ const key = collectionKey(type);
199
+ if (key === undefined) {
200
+ if (by.length > 0)
201
+ throw new Error(`${where} is an Array, cut by position, so it has no key for \`by\` to name`);
202
+ continue;
280
203
  }
281
- byParamType = StructType(shared);
282
- }
283
- else {
284
- const first = toEastTypeValue(keyTypes[0]);
285
- if (!keyTypes.every((k) => isTypeValueEqual(toEastTypeValue(k), first))) {
286
- throw new Error(`partitionTask '${name}': co-partitioned key types must be identical (or share struct fields) for boundary alignment`);
204
+ if (by.length === 0) {
205
+ cuts.push({ input: dataset.name, types: [key] });
206
+ continue;
287
207
  }
288
- byParamType = keyTypes[0];
289
- }
290
- // Reify `by` and validate the leading-prefix soundness condition per
291
- // dataset: struct keys sort lexicographically by declared field order, so
292
- // the projection must read a leading prefix of each dataset's OWN order.
293
- let byIr;
294
- let byShape = null;
295
- if (spec.by !== undefined) {
296
- const byFn = East.function([byParamType], undefined, spec.by);
297
- const bundle = byFn.toIR();
298
- const shape = partitionProjectionShape(bundle.ir);
299
- if (shape === null) {
300
- throw new Error(`partitionTask '${name}': \`by\` must project a leading prefix of the partition key — ` +
301
- `accepted shapes: the key itself, a leading field (\`key.f\`), a nested leading-field ` +
302
- `path (\`key.a.b\`, each step the first field of its level), or a struct literal of ` +
303
- `leading fields in declared order (\`{ f1: key.f1, f2: key.f2 }\`)`);
208
+ if (key.type !== 'Struct') {
209
+ throw new Error(`${where} has a ${key.type} key, which has no fields for \`by\` to name`);
304
210
  }
305
- if (shape.names.length > 0) {
306
- for (let i = 0; i < partitions.length; i++) {
307
- const keyType = keyTypes[i];
308
- if (keyType.type !== 'Struct') {
309
- throw new Error(`partitionTask '${name}': \`by\` reads key fields, but partitioned dataset '${partitions[i].name}' has a non-struct key (${keyType.type}) — use the identity projection`);
310
- }
311
- if (shape.kind === 'fields') {
312
- const order = Object.keys(keyType.fields);
313
- const misaligned = shape.names.length > order.length || shape.names.some((f, j) => order[j] !== f);
314
- if (misaligned) {
315
- throw new Error(`partitionTask '${name}': \`by\` projects (${shape.names.join(', ')}), which is not a leading prefix of ` +
316
- `partitioned dataset '${partitions[i].name}' key field order (${order.join(', ')})`);
317
- }
318
- }
319
- else {
320
- // Nested path: every step must read the FIRST field of its
321
- // level's struct — struct keys sort lexicographically by declared
322
- // field order, so only first-field descent preserves it.
323
- let level = keyType;
324
- for (let j = 0; j < shape.names.length; j++) {
325
- const step = shape.names[j];
326
- if (level.type !== 'Struct') {
327
- throw new Error(`partitionTask '${name}': \`by\` path (key.${shape.names.join('.')}) descends into a ${level.type} at '${step}' — ` +
328
- `every step must read the first field of a struct level of partitioned dataset '${partitions[i].name}'`);
329
- }
330
- const levelFields = level.fields;
331
- const order = Object.keys(levelFields);
332
- if (order[0] !== step) {
333
- throw new Error(`partitionTask '${name}': \`by\` path (key.${shape.names.slice(0, j + 1).join('.')}) reads '${step}', which is not ` +
334
- `the first field of partitioned dataset '${partitions[i].name}' key level (${order.join(', ')})`);
335
- }
336
- level = levelFields[step];
337
- }
338
- }
211
+ const fields = key.fields;
212
+ const order = Object.keys(fields);
213
+ const types = by.map((entry, i) => {
214
+ const [head, ...path] = entry.split('.');
215
+ if (order[i] !== head || (path.length > 0 && i !== by.length - 1)) {
216
+ throw new Error(`${where}: \`by\` (${by.join(', ')}) must name leading key fields in order — the key's fields are ` +
217
+ `(${order.join(', ')}), and only the last entry may read into one, as 'at.day' does`);
339
218
  }
340
- }
341
- byShape = shape;
342
- byIr = encodeEastIR(bundle);
343
- }
344
- // Under co-partitioning with non-identical key types, the EFFECTIVE
345
- // boundary projection must ALSO be a leading prefix of every dataset's
346
- // key order when it is implicit:
347
- // - with no `by`, the runtime aligns secondaries under the PRIMARY's key
348
- // comparator — the projection is the primary's full field sequence;
349
- // - with an identity `by`, it is the shared-intersection struct in its
350
- // declared field order.
351
- // A key set like {sku,period} × {period,sku} passes the field-wise
352
- // intersection yet sorts differently per dataset — accepted, it would
353
- // silently mis-assign rows at run time with a success status.
354
- if (partitions.length > 1) {
355
- const firstKey = toEastTypeValue(keyTypes[0]);
356
- const keysIdentical = keyTypes.every((k) => isTypeValueEqual(toEastTypeValue(k), firstKey));
357
- const validatedByFields = byShape !== null && byShape.names.length > 0;
358
- if (!keysIdentical && !validatedByFields) {
359
- // Non-identical keys reach here only via the struct-intersection path.
360
- const sourceFields = (t) => t.fields;
361
- const effective = spec.by !== undefined
362
- ? { label: 'the identity `by` projection reads the shared key fields', fields: sourceFields(byParamType) }
363
- : { label: `with no \`by\`, co-partition boundaries align on partitioned dataset '${partitions[0].name}' key order`, fields: sourceFields(keyTypes[0]) };
364
- const eff = Object.keys(effective.fields);
365
- for (let i = 0; i < partitions.length; i++) {
366
- const fields = sourceFields(keyTypes[i]);
367
- const order = Object.keys(fields);
368
- const misaligned = eff.length > order.length || eff.some((f, j) => order[j] !== f || !isTypeValueEqual(toEastTypeValue(effective.fields[f]), toEastTypeValue(fields[f])));
369
- if (misaligned) {
370
- throw new Error(`partitionTask '${name}': ${effective.label} (${eff.join(', ')}), which is not a leading prefix of ` +
371
- `partitioned dataset '${partitions[i].name}' key field order (${order.join(', ')}) — ` +
372
- `declare \`by\` as a shared leading-prefix projection, or re-key upstream`);
219
+ let level = fields[head];
220
+ for (const step of path) {
221
+ const levelFields = level.type === 'Struct' ? Object.keys(level.fields) : [];
222
+ if (levelFields[0] !== step) {
223
+ throw new Error(`${where}: \`by\` path '${entry}' reads '${step}', which is not the first field of ` +
224
+ `${level.type === 'Struct' ? `(${levelFields.join(', ')})` : `a ${level.type}`} — rows sort by a struct's first field, so only it groups them`);
373
225
  }
226
+ level = level.fields[step];
374
227
  }
375
- }
376
- }
377
- // Splice mode (no `combine`) assembles the output from per-partition
378
- // SHARDS in partition order — only a collection can splice. A
379
- // non-collection output would pass definition time and fail only when the
380
- // input first carves into two or more partitions.
381
- if (spec.combine === undefined && output.type !== 'Array' && output.type !== 'Set' && output.type !== 'Dict') {
382
- throw new Error(`partitionTask '${name}': without \`combine\`, each partition returns a shard of the output and the shards ` +
383
- `splice (or, with \`merge\`, merge) in key order — the output must be a collection (Array, Set or Dict), ` +
384
- `got ${output.type}. Provide \`combine\` to fold non-collection partials.`);
228
+ return level;
229
+ });
230
+ cuts.push({ input: dataset.name, types });
385
231
  }
386
- // Reify `combine` as a free East function (Out, Out) -> Out.
387
- let combineIr;
388
- if (spec.combine !== undefined) {
389
- const combineFn = East.function([output, output], output, spec.combine);
390
- combineIr = encodeEastIR(combineFn.toIR());
232
+ if (partitions.length < 2)
233
+ return;
234
+ if (cuts.length < partitions.length) {
235
+ throw new Error(`streamTask '${name}': co-partitioned inputs are cut at the same keys, so each must be a Set or a Dict — ` +
236
+ `partition one input and pass the others whole`);
391
237
  }
392
- // Reify `merge` as a free East function (Key, Value, Value) -> Value for a
393
- // Dict output, or record the Set union flag. The task's runner merges the
394
- // partials with it through its `merge` command, whose command IR is built
395
- // here and carried in the metadata — the orchestrator runs it as an
396
- // ordinary execution per group of partials.
397
- let mergeIr;
398
- let mergeSets = false;
399
- let mergeCommand;
400
- if (spec.merge !== undefined) {
401
- if (spec.combine !== undefined) {
402
- throw new Error(`partitionTask '${name}': \`merge\` and \`combine\` are two assembly modes — give one. ` +
403
- `\`merge\` merges keyed partials by key; \`combine\` folds whole partials.`);
238
+ const [first, ...rest] = cuts;
239
+ for (const other of rest) {
240
+ const same = other.types.length === first.types.length &&
241
+ other.types.every((t, i) => isTypeValueEqual(toEastTypeValue(t), toEastTypeValue(first.types[i])));
242
+ if (!same) {
243
+ throw new Error(`streamTask '${name}': co-partitioned inputs '${first.input}' and '${other.input}' have no common key — ` +
244
+ `they are cut by (${first.types.map((t) => printType(t)).join(', ')}) and (${other.types.map((t) => printType(t)).join(', ')}); ` +
245
+ `give each a \`by\` naming fields of the same types`);
404
246
  }
405
- if (spec.runner?.runtime === 'custom') {
406
- throw new Error(`partitionTask '${name}': merge runs the fan-in on the task's runner, which must be a stock runtime ` +
407
- `(east-node, east-py, east-c) — the custom runtime has no merge command`);
408
- }
409
- if (spec.merge === 'union') {
410
- if (output.type !== 'Set') {
411
- throw new Error(`partitionTask '${name}': \`merge: 'union'\` assembles a Set output, got ${output.type}` +
412
- (output.type === 'Dict' ? ' — a Dict output takes a per-key merge function' : ''));
413
- }
414
- mergeSets = true;
415
- }
416
- else {
417
- if (output.type !== 'Dict') {
418
- throw new Error(`partitionTask '${name}': a \`merge\` FUNCTION resolves a key present in two partials, so the output must be a Dict, ` +
419
- `got ${output.type}${output.type === 'Set' ? " — a Set output takes `merge: 'union'`" : ''}`);
420
- }
421
- const keyType = output.key;
422
- const valueType = output.value;
423
- const mergeFn = East.function([keyType, valueType, valueType], valueType, spec.merge);
424
- mergeIr = encodeEastIR(mergeFn.toIR());
425
- }
426
- mergeCommand = encodeEastIR(mergeCommandIr(runnerToVariant(spec.runner ?? DEFAULT_RUNNER), mergeSets ? 'union' : 'function'));
427
- }
428
- const targetPartitionBytes = spec.targetPartitionBytes ?? DEFAULT_TARGET_PARTITION_BYTES;
429
- if (!Number.isInteger(targetPartitionBytes) || targetPartitionBytes <= 0) {
430
- throw new Error(`partitionTask '${name}': targetPartitionBytes must be a positive integer, got ${spec.targetPartitionBytes}`);
431
247
  }
432
- const bodyFn = East.function([...partitions.map((p) => p.type), ...inputs.map((i) => i.type)], output, fn);
433
- const metadata = encodePartitionTaskMetadata({
434
- partitions: BigInt(partitions.length),
435
- by: byIr !== undefined ? some(byIr) : none,
436
- combine: combineIr !== undefined ? some(combineIr) : none,
437
- targetPartitionBytes: BigInt(targetPartitionBytes),
438
- merge: mergeIr !== undefined ? some(mergeIr) : none,
439
- mergeSets,
440
- mergeCommand: mergeCommand !== undefined ? some(mergeCommand) : none,
441
- });
442
- return task(name, [...partitions, ...inputs], bodyFn, {
443
- kind: TASK_KIND_PARTITION,
444
- metadata,
445
- ...(spec.runner !== undefined && { runner: spec.runner }),
446
- ...(spec.environment !== undefined && { environment: spec.environment }),
447
- });
448
248
  }
449
249
  /**
450
- * Defines a streaming task: one execution whose runner feeds the `stream`
451
- * input segment-by-segment in canonical order (state is ordinary `$.let`
452
- * locals) and writes the output incrementally through the `emit` capability
453
- * — bounded memory at both ends, exact left-fold semantics, no parallelism
454
- * and no partial recompute.
250
+ * Defines a stream task: a body that emits its output into an output kind
251
+ * rather than returning it, over inputs its work may be split across.
455
252
  *
456
- * `emit` is a runner-implemented function value: `emit(key, value)` for Dict
457
- * outputs, `emit(element)` for Array/Set outputs. The streaming writer
458
- * re-batches emissions byte-adaptively and writes the output in one pass,
459
- * with one open batch in memory whatever the output's size. An Array output
460
- * takes its elements in emission order; a Set or Dict output must be emitted
461
- * in ascending key order, and an out-of-order key fails the task (`beast2
462
- * v5: Dict key emitted out of order: 1 after 2 — Set/Dict emissions must
463
- * ascend in East order`, the same words on every runtime). A re-key whose
464
- * output keys do not arrive in order is a `partitionTask` with `merge`.
465
- * Duplicate Set/Dict keys are a runtime error unless `merge` folds them: a
466
- * function for a Dict output (adjacent equal keys fold left in emission
467
- * order), `'union'` for a Set output. The body's return value is unused —
468
- * the output dataset is what `emit` wrote.
253
+ * `emit` is the body's trailing parameter, and the output kind fixes its
254
+ * signature: `emit(t)` for `array`, `set` and `fold`, `emit(k, v)` for
255
+ * `dict`. Emission order is free — the platform sorts sets and dicts — and
256
+ * the parts of the output combine as the kind says: concatenated for an
257
+ * array, united for a set, by key for a dict (equal keys folding with its
258
+ * `merge`), and folded with `combine` from `zero` for a fold.
469
259
  *
470
- * With no `stream`, the task is a producer: its body loops over
471
- * platform-function sources (paginated APIs, database cursors) and emits.
472
- * A producer's real input is the outside world, so it follows the existing
473
- * integration-task conventions for re-run semantics — `emit` changes nothing
474
- * about that.
260
+ * An input wrapped in {@link partition} is one the work may be split over:
261
+ * the body runs once per piece, each piece typed as the whole dataset, and
262
+ * the pieces' outputs combine by the output kind. The other inputs reach
263
+ * every piece whole. With no partitioned input the task is one unit, with
264
+ * exact left-to-right semantics; a producer has no inputs at all, and emits
265
+ * what it reads from platform functions.
266
+ *
267
+ * The author's contract, the only one: `merge` and `combine` are associative,
268
+ * `zero` is an identity of `combine`, and a partitioned body's combined
269
+ * result does not depend on where its input was cut.
475
270
  *
476
271
  * @typeParam Name - Task name (literal type)
477
- * @typeParam Output - Output collection type
478
- * @typeParam Stream - Streamed input dataset def, when present
479
- * @typeParam Inputs - Ordinary input dataset defs
272
+ * @typeParam Inputs - The inputs, some marked with {@link partition}
273
+ * @typeParam Output - The output kind
480
274
  * @param name - Task name
481
- * @param spec - The declaration: stream input, ordinary inputs, output
482
- * collection type, merge, runner and environment
483
- * @param fn - Implementation body; receives the stream value (when
484
- * declared), the inputs, then `emit`, and returns nothing
275
+ * @param spec - The inputs, the output kind, the runner and the environment
276
+ * @param fn - The body; receives the inputs, then `emit`, and returns nothing
485
277
  * @returns A TaskDef with `.output` for chaining
486
- * @throws {Error} When the output is not a collection type, the runner is
487
- * the `custom` runtime, or `merge` is given for an Array output or in the
488
- * wrong form for the output's kind.
278
+ * @throws {Error} When the output is not an output kind, the runner is the
279
+ * `custom` runtime, a partitioned input is not a collection, a `by` names
280
+ * something other than leading key fields, or co-partitioned inputs have no
281
+ * common key.
489
282
  *
490
283
  * @example
491
284
  * ```ts
492
- * const events = e3.input('events', ArrayType(EventType));
285
+ * const sales = e3.input('sales', DictType(SaleKeyType, SaleType));
493
286
  *
494
- * const balances = e3.streamTask('balances', {
495
- * stream: events,
496
- * output: ArrayType(BalanceType),
497
- * }, ($, events, emit) => {
498
- * const balance = $.let(0.0);
499
- * $.for(events, ($, event) => {
500
- * $.assign(balance, balance.add(event.amount));
501
- * $(emit({ at: event.at, balance }));
287
+ * const byAccount = e3.streamTask('by_account', {
288
+ * inputs: [e3.partition(sales)],
289
+ * output: e3.output.dict(StringType, FloatType, { merge: ($, account, a, b) => a.add(b) }),
290
+ * }, ($, sales, emit) => {
291
+ * $.for(sales, ($, sale) => {
292
+ * $(emit(sale.account, sale.amount));
502
293
  * });
503
294
  * });
504
295
  * ```
@@ -506,92 +297,35 @@ export function partitionTask(name, spec, fn) {
506
297
  export function streamTask(name, spec, fn) {
507
298
  if (spec.environment)
508
299
  validateEnvironmentDecl(spec.environment, name);
509
- const output = spec.output;
510
- const inputs = (spec.inputs ?? []);
511
- let emitKind;
512
- let emitType;
513
- switch (output.type) {
514
- case 'Dict':
515
- emitKind = 'dict';
516
- emitType = FunctionType([output.key, output.value], NullType);
517
- break;
518
- case 'Set':
519
- emitKind = 'set';
520
- emitType = FunctionType([output.key], NullType);
521
- break;
522
- case 'Array':
523
- emitKind = 'array';
524
- emitType = FunctionType([output.value], NullType);
525
- break;
526
- default:
527
- throw new Error(`streamTask '${name}': output must be a collection type (Array, Set or Dict), got ${output.type} — for small results use e3.task`);
300
+ const outputKind = spec.output;
301
+ if (!['array', 'set', 'dict', 'fold'].includes(outputKind?.kind)) {
302
+ throw new Error(`streamTask '${name}': output is an output kind — e3.output.array, set, dict or fold`);
528
303
  }
529
304
  const runner = spec.runner ?? DEFAULT_RUNNER;
530
305
  if (runner.runtime === 'custom') {
531
- throw new Error(`streamTask '${name}': the custom runtime cannot carry the streaming flags — use a stock runtime (east-node, east-py, east-c)`);
532
- }
533
- // `merge` folds equal keys in the runner's sink: a function for a Dict
534
- // output, reified as a free East function (Key, Value, Value) -> Value;
535
- // `'union'` for a Set output.
536
- let mergeMode = 'none';
537
- let mergeIR;
538
- if (spec.merge !== undefined) {
539
- if (output.type === 'Dict' && typeof spec.merge === 'function') {
540
- mergeMode = 'function';
541
- mergeIR = East.function([output.key, output.value, output.value], output.value, spec.merge).toIR();
542
- }
543
- else if (output.type === 'Set' && spec.merge === 'union') {
544
- mergeMode = 'union';
545
- }
546
- else {
547
- const given = output.type === 'Array' ? '' : typeof spec.merge === 'function' ? ' with a function' : ` with '${String(spec.merge)}'`;
548
- throw new Error(`streamTask '${name}': merge applies to Dict (a function) or Set ('union') outputs, got ${output.type}${given}`);
549
- }
306
+ throw new Error(`streamTask '${name}': the custom runtime runs only a program that returns its output — ` +
307
+ `use a stock runtime (east-node, east-py, east-c)`);
550
308
  }
551
- const paramTypes = [
552
- ...(spec.stream !== undefined ? [spec.stream.type] : []),
553
- ...inputs.map((i) => i.type),
554
- emitType,
555
- ];
556
- const bodyFn = East.function(paramTypes, NullType, fn);
557
- const eastIR = bodyFn.toIR();
558
- // Wire inputs: the body IR, the merge IR (function mode), the stream, then
559
- // the ordinary inputs — the layout streamCommandIr's argv reads.
309
+ const inputs = spec.inputs;
310
+ checkPartitions(name, inputs.filter((i) => i.kind === 'partition'));
311
+ const datasets = inputs.map((i) => (i.kind === 'partition' ? i.dataset : i));
312
+ const parameters = [...datasets.map((d) => d.type), outputKind.emit];
313
+ const bodyFn = East.function(parameters, NullType, fn);
560
314
  const taskTree = createTaskTree(name);
561
- const functionIRDataset = createIRDataset(name, taskTree, 'function_ir', eastIR);
562
- const input_datasets = [
563
- functionIRDataset,
564
- ...(mergeIR !== undefined ? [createIRDataset(name, taskTree, 'merge_ir', mergeIR)] : []),
565
- ...(spec.stream !== undefined ? [spec.stream] : []),
566
- ...inputs,
567
- ];
568
- const outputDataset = createOutputDataset(name, taskTree, output);
569
- // The runner constructs the emit sink for the output kind, folding equal
570
- // keys as `merge` says, and feeds the stream (always the first `-i` input)
571
- // lazily instead of whole-decoding it.
572
- const command = streamCommandIr(runnerToVariant(runner), {
573
- emit: emitKind,
574
- merge: mergeMode,
575
- stream: spec.stream !== undefined ? 'first' : 'none',
576
- });
577
- const metadata = encodeStreamTaskMetadata({
578
- stream: spec.stream !== undefined,
579
- emit: emitKind,
580
- merge: mergeMode,
581
- });
315
+ const output = createOutputDataset(name, taskTree, outputKind.type);
582
316
  const taskDef = {
583
317
  kind: 'task',
584
318
  name,
585
- command,
586
- inputs: input_datasets,
587
- output: outputDataset,
588
- deps: collectDeps(taskTree, outputDataset, input_datasets),
589
- taskKind: TASK_KIND_STREAM,
590
- metadata,
319
+ body: { kind: 'east', program: bodyFn.toIR() },
320
+ inputs,
321
+ output,
322
+ outputKind,
323
+ role: variant('data', null),
324
+ deps: collectDeps(taskTree, output, datasets),
591
325
  runner,
592
- ...(spec.environment !== undefined && { environment: spec.environment }),
326
+ environment: spec.environment,
593
327
  };
594
- outputDataset.deps.add(taskDef);
328
+ output.deps.add(taskDef);
595
329
  return taskDef;
596
330
  }
597
331
  //# sourceMappingURL=task.js.map