@forzalabs/remora 2.4.0 → 2.5.0

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.
@@ -0,0 +1,2224 @@
1
+ # Remora - Consumers
2
+
3
+ ## **Overview**
4
+
5
+ Consumers in Remora define how data is translated, transformed, combined, and output from producers. They act as the data transformation and output layer, providing field selection, data joins, filtering, transformations, trigger configuration, and export capabilities.
6
+
7
+ Each consumer configuration specifies:
8
+
9
+ - **Data sources** (which producers to consume from)
10
+ - **Field selection** (which fields to include and how to transform them)
11
+ - **Filtering** (how to subset the data)
12
+ - **Transformation** (how data is changed)
13
+ - **Output formats** (where, when and how to export the processed data)
14
+
15
+ ## **Core Components**
16
+
17
+ ### **Consumer Structure**
18
+
19
+ All consumers follow this basic structure:
20
+
21
+ ```json
22
+ {
23
+ "$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
24
+ "name": "consumer-name",
25
+ "description": "Consumer description",
26
+ "producers": [/* data sources */ ],
27
+ "fields": [/* field selection and transformation */ ],
28
+ "outputs": [/* export configurations */ ],
29
+ "filters": [/* data filtering */ ],
30
+ "options": {/* processing options */ },
31
+ "validate": [/* dataset-level validations */ ],
32
+ "split": {/* break the output into several files */ },
33
+ "metadata": {/* custom tags */ },
34
+ "project": "project-name",
35
+ "schema": "schema-name",
36
+ "_version": 1
37
+ }
38
+ ```
39
+
40
+ ### **Consumer Types by Use Case**
41
+
42
+ | Use Case | Description | Key Features |
43
+ | --- | --- | --- |
44
+ | **Simple Extraction** | Basic field selection from one producer | Field selection, aliasing, transformations |
45
+ | **Data Joining** | Combine data from multiple producers | JOIN operations, relationship definitions |
46
+ | **Data Union** | Merge datasets with same structure | UNION operations, data consolidation |
47
+ | **Analytics** | Complex transformations and aggregations | Calculated fields, filtering, pivots |
48
+ | **Export Pipeline** | Automated data export with scheduling | Triggers, multiple output formats |
49
+
50
+ ---
51
+
52
+ ## **Basic Consumer Configuration**
53
+
54
+ ### **Simple Field Selection**
55
+
56
+ The most basic consumer selects all fields from a single producer:
57
+
58
+ ```json
59
+ {
60
+ "$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
61
+ "name": "simple-consumer",
62
+ "description": "Basic field selection",
63
+ "producers": [
64
+ { "name": "producer-name" }
65
+ ],
66
+ "fields": [
67
+ { "key": "*" }
68
+ ],
69
+ "outputs": [
70
+ { "format": "JSON", "exportDestination": "destination" }
71
+ ],
72
+ "_version": 1
73
+ }
74
+ ```
75
+
76
+ ### **Selective Field Configuration**
77
+
78
+ Select specific fields with aliases and transformations:
79
+
80
+ ```json
81
+ {
82
+ "$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
83
+ "name": "selective-consumer",
84
+ "description": "Selective field extraction with aliases",
85
+ "producers": [
86
+ { "name": "customer-data" }
87
+ ],
88
+ "fields": [
89
+ { "key": "customer_id", "alias": "Customer ID" },
90
+ { "key": "first_name", "alias": "First Name" },
91
+ { "key": "email", "alias": "Email Address" },
92
+ { "key": "registration_date", "alias": "Registration Date" }
93
+ ],
94
+ "outputs": [
95
+ { "format": "JSON", "exportDestination": "local-output" }
96
+ ],
97
+ "_version": 1
98
+ }
99
+ ```
100
+
101
+ ---
102
+
103
+ ## **Producer Configuration**
104
+
105
+ Producers define the data sources that consumers pull from. Each consumer can have multiple producers for joining or union operations.
106
+
107
+ ### **Producer Properties**
108
+
109
+ | Property | Required | Description | Example |
110
+ | --- | --- | --- | --- |
111
+ | `name` | ✅ | Producer **or consumer** name to consume from (see *Consumer as Input*) | `"customer-data"`, `"order-history"` |
112
+ | `joins` | ❌ | JOIN configurations for multi-producer consumers | Array of join definitions |
113
+ | `union` | ❌ | Union data instead of joining | `true`, `false` (default) |
114
+ | `isOptional` | ❌ | If the producer's data is missing at run time (e.g. the file isn't present), don't fail — create a placeholder dataset and continue | `true`, `false` (default) |
115
+
116
+ ### **Single Producer**
117
+
118
+ ```json
119
+ {
120
+ "producers": [
121
+ { "name": "sales-data" }
122
+ ]
123
+ }
124
+ ```
125
+
126
+ ### **Multiple Producers with Joins**
127
+
128
+ ```json
129
+ {
130
+ "producers": [
131
+ { "name": "customers" },
132
+ {
133
+ "name": "orders",
134
+ "joins": [
135
+ {
136
+ "otherName": "customers",
137
+ "relationship": "many-to-one",
138
+ "sql": "${P.customer_id} = ${customers.customer_id}"
139
+ }
140
+ ]
141
+ }
142
+ ]
143
+ }
144
+ ```
145
+
146
+ ### **Union Operations**
147
+
148
+ ```json
149
+ {
150
+ "producers": [
151
+ { "name": "sales-2023", "union": true },
152
+ { "name": "sales-2024", "union": true }
153
+ ]
154
+ }
155
+ ```
156
+
157
+ ### **Consumer as Input**
158
+
159
+ A producer entry can name another **consumer** instead of a producer. That consumer is run first and its **full output** is streamed in as this consumer's input — the way to chain pipelines, where one consumer transforms the output of another.
160
+
161
+ This is different from `code_lookup`, which pulls another consumer's output into a small in-memory `code → value` table. A consumer input is the whole dataset: it is materialized to disk and chunked across workers, so the upstream output can be arbitrarily large.
162
+
163
+ The upstream consumer's **output fields** (final names / aliases, not its underlying source columns) become the available columns. Its records are combined with any other producers via union, then shaped by this consumer's own fields, filters, and transforms. Chains are supported (a consumer input whose upstream also has consumer inputs), with cycle and depth detection.
164
+
165
+ ```json
166
+ {
167
+ "producers": [
168
+ { "name": "c_active_accounts" }
169
+ ],
170
+ "fields": [
171
+ { "key": "id" },
172
+ { "key": "country", "alias": "country_upper", "transform": { "to_uppercase": true } }
173
+ ],
174
+ "filters": [
175
+ { "rule": { "member": "plan", "operator": "equals", "values": ["enterprise"] } }
176
+ ]
177
+ }
178
+ ```
179
+
180
+ Here `c_active_accounts` is another consumer; `c_active_accounts` runs, and this consumer reads its output, keeps enterprise rows, and uppercases `country`.
181
+
182
+ ---
183
+
184
+ ## **Field Configuration**
185
+
186
+ Fields define which data columns to include in the consumer output and how to transform them.
187
+
188
+ ### **Field Properties**
189
+
190
+ | Property | Required | Description | Example |
191
+ | --- | --- | --- | --- |
192
+ | `key` | ✅ | Field name from producer, `*` for all, or a pattern holding a `*` (see [Pattern Keys](#pattern-keys)) | `"customer_id"`, `"*"`, `"addr*Street"` |
193
+ | `description` | ❌ | Optional human-readable description of the field. No runtime effect, but it is shown under the field in `remora ui-preview` and the field filter matches it | Descriptive text |
194
+ | `alias` | ❌ | New name for the field in output. A template when the key is a pattern | `"Customer ID"`, `"street_*"`, `"{key}_iso"` |
195
+ | `from` | ❌ | Producer to get field from (for ambiguous fields) | `"customers"`, `"orders"` |
196
+ | `transform` | ❌ | Data transformations to apply | Transformation objects |
197
+ | `default` | ❌ | Default value if field is missing | `"Unknown"`, `0`, `null` |
198
+ | `fixed` | ❌ | Use default value always (don't look in data) | `true`, `false` |
199
+ | `hidden` | ❌ | Include in processing but exclude from output | `true`, `false` |
200
+ | `onError` | ❌ | Error handling strategy | `"set_default"`, `"skip"`, `"fail"` |
201
+ | `validate` | ❌ | Field-level data-quality rules (see [Field Validation](#field-validation)) | Array of `{ rule, onFail }` |
202
+ | `copyFrom` | ❌ | Add a new field that is a value copy of an earlier field (copies the value *before* that field's transformations) | `"amount"`, `"Sale Amount"` |
203
+ | `lineCount` | ❌ | Populate the field with the 1-based source line number of each record (consistent across parallel workers) | `true`, `false` |
204
+ | `position` | ⚠️ | `FIXED` output only, and required on every visible field of one: the columns this field occupies and how its value is filled out to them (see [FIXED Output](#fixed-output)) | `{ "width": 10, "start": 21 }` |
205
+
206
+ A field's **output name** is its `alias` when it has one and its `key` otherwise. Two fields resolving to the same output name are rejected at `compile`: a record is keyed by that name, so a collision never failed at run time — one field simply overwrote the other.
207
+
208
+ ### **Field Selection Patterns**
209
+
210
+ ### **All Fields**
211
+
212
+ ```json
213
+ {
214
+ "fields": [
215
+ { "key": "*" }
216
+ ]
217
+ }
218
+ ```
219
+
220
+ ### **Specific Fields**
221
+
222
+ ```json
223
+ {
224
+ "fields": [
225
+ { "key": "customer_id" },
226
+ { "key": "first_name" },
227
+ { "key": "email" }
228
+ ]
229
+ }
230
+ ```
231
+
232
+ ### **Fields with Aliases**
233
+
234
+ ```json
235
+ {
236
+ "fields": [
237
+ { "key": "customer_id", "alias": "Customer ID" },
238
+ { "key": "first_name", "alias": "First Name" },
239
+ { "key": "order_total", "alias": "Total Amount" }
240
+ ]
241
+ }
242
+ ```
243
+
244
+ ### **Pattern Keys**
245
+
246
+ A key holding a `*` selects **every field whose name matches**, and each match keeps the rest of the field's configuration. One entry then stands in for a whole family of columns — which is what makes the columns a [repeating group](Remora%20-%20Producers.md#repeating-groups-occurs) generates usable without writing one field per occurrence:
247
+
248
+ ```json
249
+ {
250
+ "fields": [
251
+ { "key": "member_id" },
252
+ { "key": "addr*Street", "alias": "street_*" },
253
+ { "key": "addr*State", "alias": "{key}_upper", "transform": { "to_uppercase": true }, "onError": "skip" }
254
+ ]
255
+ }
256
+ ```
257
+
258
+ Against a producer with three address occurrences that yields `street_0`, `street_1`, `street_2`, `addr0State_upper`, `addr1State_upper` and `addr2State_upper` — and the `to_uppercase` transform, declared once, runs on each of the three matches.
259
+
260
+ Since several columns come out of one entry, `alias` becomes a **template** that has to vary per match:
261
+
262
+ | Token | Replaced by | Example (`key: "addr*Street"` matching `addr0Street`) |
263
+ | --- | --- | --- |
264
+ | `*` | What the pattern's wildcard captured, positionally | `"street_*"` gives `street_0` |
265
+ | `{key}` | The full matched field name | `"{key}_iso"` gives `addr0Street_iso` |
266
+
267
+ Notes:
268
+
269
+ - Matching is literal apart from the wildcards — a pattern is never a regular expression.
270
+ - A pattern is expanded **where it is declared**, and its matches follow the producer's own column order.
271
+ - A pattern that matches **nothing** is rejected at `compile`, as is an `alias` that would give two matches the same output name. So is a pattern combined with `fixed`, `copyFrom` or `lineCount`: those fields don't read a producer column, so there is nothing to match.
272
+ - Add `"onError": "skip"` when a transform can't handle every match — over a sparse repeating group most occurrences are `null`, and a transform like `to_uppercase` rejects a non-string value rather than passing it through.
273
+ - The bare `"key": "*"` keeps its own meaning: every column of one producer, under its own name.
274
+
275
+ ### **Fields from Specific Producers**
276
+
277
+ ```json
278
+ {
279
+ "fields": [
280
+ { "key": "customer_id", "from": "customers" },
281
+ { "key": "order_date", "from": "orders" },
282
+ { "key": "product_name", "from": "products" }
283
+ ]
284
+ }
285
+ ```
286
+
287
+ ### **Fixed Fields**
288
+
289
+ ```json
290
+ {
291
+ "fields": [
292
+ { "key": "report_date", "default": "2024-01-01", "fixed": true },
293
+ { "key": "version", "default": "1.0", "fixed": true }
294
+ ]
295
+ }
296
+ ```
297
+
298
+ ### **Copied Fields**
299
+
300
+ `copyFrom` adds a new field whose value is a copy of another field that appears **earlier** in the list. The value is copied *before* the source field's own transformations are applied, so the two fields can then diverge. Reference the source by its `alias` if it has one, otherwise its `key`.
301
+
302
+ ```json
303
+ {
304
+ "fields": [
305
+ { "key": "amount", "alias": "raw_amount" },
306
+ { "key": "amount", "alias": "amount_with_tax", "copyFrom": "raw_amount", "transform": { "multiply": 1.08 } }
307
+ ]
308
+ }
309
+ ```
310
+
311
+ ### **Line Number Fields**
312
+
313
+ Set `lineCount` to populate a field with the 1-based source line number of each record. The value stays consistent even when a file is processed in parallel across multiple workers.
314
+
315
+ ```json
316
+ {
317
+ "fields": [
318
+ { "key": "row_number", "lineCount": true },
319
+ { "key": "customer_id" }
320
+ ]
321
+ }
322
+ ```
323
+
324
+ ---
325
+
326
+ ## **Field Transformations**
327
+
328
+ Transformations allow you to modify field values during processing.
329
+
330
+ ### **Data Type Transformations**
331
+
332
+ ### **Cast Operations**
333
+
334
+ ```json
335
+ {
336
+ "key": "birth_date",
337
+ "transform": { "cast": "datetime", "format": "YYYY-MM-DD" }
338
+ }
339
+ ```
340
+
341
+ ```json
342
+ {
343
+ "key": "price",
344
+ "transform": { "cast": "number" }
345
+ }
346
+ ```
347
+
348
+ ### **String Transformations**
349
+
350
+ ### **Case Conversions**
351
+
352
+ ```json
353
+ {
354
+ "key": "name",
355
+ "transform": { "to_uppercase": true }
356
+ }
357
+ ```
358
+
359
+ ```json
360
+ {
361
+ "key": "email",
362
+ "transform": { "to_lowercase": true }
363
+ }
364
+ ```
365
+
366
+ ```json
367
+ {
368
+ "key": "title",
369
+ "transform": { "capitalize": true }
370
+ }
371
+ ```
372
+
373
+ ### **String Manipulation**
374
+
375
+ ```json
376
+ {
377
+ "key": "description",
378
+ "transform": { "trim": true }
379
+ }
380
+ ```
381
+
382
+ ```json
383
+ {
384
+ "key": "year",
385
+ "transform": { "substring": { "start": 0, "end": 4 } }
386
+ }
387
+ ```
388
+
389
+ ```json
390
+ {
391
+ "key": "code",
392
+ "transform": { "pad_start": { "length": 6, "char": "0" } }
393
+ }
394
+ ```
395
+
396
+ ```json
397
+ {
398
+ "key": "name",
399
+ "transform": { "prepend": "Mr. " }
400
+ }
401
+ ```
402
+
403
+ ```json
404
+ {
405
+ "key": "filename",
406
+ "transform": { "append": ".txt" }
407
+ }
408
+ ```
409
+
410
+ ### **String Splitting**
411
+
412
+ ```json
413
+ {
414
+ "key": "full_name",
415
+ "alias": "first_name",
416
+ "transform": { "split": { "separator": " ", "index": 0 } }
417
+ }
418
+ ```
419
+
420
+ ### **Numeric Transformations**
421
+
422
+ ### **Mathematical Operations**
423
+
424
+ ```json
425
+ {
426
+ "key": "price",
427
+ "alias": "price_with_tax",
428
+ "transform": { "multiply": 1.08 }
429
+ }
430
+ ```
431
+
432
+ ```json
433
+ {
434
+ "key": "base_score",
435
+ "alias": "adjusted_score",
436
+ "transform": { "add": 10 }
437
+ }
438
+ ```
439
+
440
+ ### **Referencing Other Fields**
441
+
442
+ `multiply`, `add`, `prepend`, `append`, and the `then`/`else`/`default` of `conditional` and `switch_case` accept a `"$fieldName"` string instead of a literal — this resolves to the current value of another field in the same record (matched by its alias if set, otherwise its key). Use `"$$"` to escape a literal leading `$`.
443
+
444
+ ```json
445
+ {
446
+ "key": "net_amount",
447
+ "alias": "total",
448
+ "transform": { "add": "$tax_amount" }
449
+ }
450
+ ```
451
+
452
+ The referenced field must be **present** in the record: if it isn't, the transformation fails and the field's `onError` policy applies. A referenced field that is present but `null` is treated as real data and passed to the operation rather than failing the row — in `multiply`/`add` it counts as `0`, and in `prepend`/`append` it concatenates as the text `null`.
453
+
454
+ ### **Multiplying / Adding Multiple Fields**
455
+
456
+ `multiplyBy` and `addBy` combine several fields together (rather than a single factor/addend):
457
+
458
+ ```json
459
+ {
460
+ "key": "line_total",
461
+ "transform": { "multiplyBy": { "fields": ["quantity", "unit_price"] } }
462
+ }
463
+ ```
464
+
465
+ ```json
466
+ {
467
+ "key": "grand_total",
468
+ "transform": { "addBy": { "fields": ["subtotal", "tax", "shipping"] } }
469
+ }
470
+ ```
471
+
472
+ ### **Date/Time Transformations**
473
+
474
+ ### **Date Component Extraction**
475
+
476
+ ```json
477
+ {
478
+ "key": "order_date",
479
+ "alias": "order_year",
480
+ "transform": { "extract": "year" }
481
+ }
482
+ ```
483
+
484
+ ```json
485
+ {
486
+ "key": "timestamp",
487
+ "alias": "hour_of_day",
488
+ "transform": { "extract": "hour" }
489
+ }
490
+ ```
491
+
492
+ `extract` supports `year`, `month`, `day`, `hour`, and `minute`.
493
+
494
+ ### **Date Reformatting**
495
+
496
+ `date_format` reformats a date string from one representation to another using [dayjs](https://day.js.org/docs/en/display/format) tokens — distinct from `cast`, which only parses or formats with a single token set. `strict` parsing defaults to `true`.
497
+
498
+ ```json
499
+ {
500
+ "key": "birth_date",
501
+ "transform": { "date_format": { "input_fmt": "DDMMYYYY", "output_fmt": "YYYY-MM-DD" } }
502
+ }
503
+ ```
504
+
505
+ **Day-of-year ("Julian") dates** — common in mainframe flat files, where a date is written as a year plus the ordinal day within it — are read with `DDD` or `DDDD`, always 3 digits:
506
+
507
+ ```json
508
+ {
509
+ "key": "service_date",
510
+ "transform": { "date_format": { "input_fmt": "YYYYDDD", "output_fmt": "YYYY-MM-DD" } }
511
+ }
512
+ ```
513
+
514
+ `2007305` reads as `2007-11-01`. A day-of-year format combines with `YYYY`/`YY`, `HH`, `mm`, `ss`, `SSS` and literal separators, but not with `MM`/`DD` — a date can't declare both an ordinal day and a calendar one. The same tokens work in a dimension's `format` on the producer side.
515
+
516
+ A format built on a token dayjs can only *write* and not read (`Do`, `Q`, `w`, `W`, `k`, `gggg`, `z`) is **rejected** with an error naming the token, rather than parsed loosely into a plausible wrong date.
517
+
518
+ ### **Regular Expression Transformations**
519
+
520
+ ### **Pattern Matching**
521
+
522
+ ```json
523
+ {
524
+ "key": "email",
525
+ "alias": "is_valid_email",
526
+ "transform": { "regex_match": { "pattern": "^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$" } }
527
+ }
528
+ ```
529
+
530
+ ### **Pattern Replacement**
531
+
532
+ ```json
533
+ {
534
+ "key": "phone",
535
+ "alias": "formatted_phone",
536
+ "transform": { "regex_replace": { "pattern": "(\\d{3})(\\d{3})(\\d{4})", "replacement": "($1) $2-$3" } }
537
+ }
538
+ ```
539
+
540
+ ### **Pattern Extraction**
541
+
542
+ ```json
543
+ {
544
+ "key": "address",
545
+ "alias": "zip_code",
546
+ "transform": { "regex_extract": { "pattern": "\\d{5}(-\\d{4})?", "group": 0 } }
547
+ }
548
+ ```
549
+
550
+ ### **Conditional Transformations**
551
+
552
+ ### **Simple Conditions**
553
+
554
+ ```json
555
+ {
556
+ "key": "age",
557
+ "alias": "age_group",
558
+ "transform": {
559
+ "conditional": {
560
+ "clauses": [
561
+ { "if": { "less_than": 18 }, "then": "Minor" },
562
+ { "if": { "less_than": 65 }, "then": "Adult" }
563
+ ],
564
+ "else": "Senior"
565
+ }
566
+ }
567
+ }
568
+ ```
569
+
570
+ ### **Complex Conditions**
571
+
572
+ ```json
573
+ {
574
+ "key": "score",
575
+ "alias": "grade",
576
+ "transform": {
577
+ "conditional": {
578
+ "clauses": [
579
+ { "if": { "greater_than_or_equal": 90 }, "then": "A" },
580
+ { "if": { "greater_than_or_equal": 80 }, "then": "B" },
581
+ { "if": { "greater_than_or_equal": 70 }, "then": "C" },
582
+ { "if": { "greater_than_or_equal": 60 }, "then": "D" }
583
+ ],
584
+ "else": "F"
585
+ }
586
+ }
587
+ }
588
+ ```
589
+
590
+ Clauses are evaluated in order and the first match wins; `else` is optional (if omitted, the original value is kept). The `if` condition supports these operators:
591
+
592
+ | Operator | Meaning |
593
+ | --- | --- |
594
+ | `greater_than`, `greater_than_or_equal` | Numeric `>` / `>=` |
595
+ | `less_than`, `less_than_or_equal` | Numeric `<` / `<=` |
596
+ | `equals`, `not_equals` | Strict (in)equality |
597
+ | `in`, `not_in` | Value (not) in a list |
598
+ | `starts_with`, `ends_with` | String prefix / suffix |
599
+ | `contains`, `not_contains` | String (not) containing a substring |
600
+ | `is_empty`, `is_not_empty` | Value is (not) null/undefined/blank |
601
+
602
+ ### **Value Mapping (switch_case)**
603
+
604
+ `switch_case` maps specific input values to outputs by strict equality (evaluated in order, first match wins). `default` is used when no case matches (optional — otherwise the original value is kept).
605
+
606
+ ```json
607
+ {
608
+ "key": "status_code",
609
+ "alias": "status",
610
+ "transform": {
611
+ "switch_case": {
612
+ "cases": [
613
+ { "when": "A", "then": "Active" },
614
+ { "when": "I", "then": "Inactive" }
615
+ ],
616
+ "default": "Unknown"
617
+ }
618
+ }
619
+ }
620
+ ```
621
+
622
+ ### **Masking**
623
+
624
+ `mask` obfuscates a value for data privacy. Options: `hash` (one-way hash), `mask` (replace with asterisks), `crypt` (reversible encryption), `random` (random value), `seeded-random` (consistent random value per input), `none`.
625
+
626
+ ```json
627
+ {
628
+ "key": "ssn",
629
+ "transform": { "mask": "hash" }
630
+ }
631
+ ```
632
+
633
+ ### **Bit Extraction**
634
+
635
+ `bit_extract` reads a single bit from an integer bitmask (e.g. decoding a packed flag byte). `position` is a 0-based index from the least-significant bit; set `as_boolean` to return `true`/`false` instead of `1`/`0`.
636
+
637
+ ```json
638
+ {
639
+ "key": "flags",
640
+ "alias": "is_active",
641
+ "transform": { "bit_extract": { "position": 0, "as_boolean": true } }
642
+ }
643
+ ```
644
+
645
+ ### **Code Lookup**
646
+
647
+ `code_lookup` maps a code to a value using the output of **another consumer** as the lookup table. The referenced consumer is executed to build a `code → value` table (keep it small and cheap). If the code isn't found and no `default` is given, the field's `onError` policy applies.
648
+
649
+ ```json
650
+ {
651
+ "key": "plan_code",
652
+ "alias": "plan_tier",
653
+ "transform": {
654
+ "code_lookup": {
655
+ "consumer": "c_lookup_plans",
656
+ "key": "plan_code",
657
+ "value": "plan_tier",
658
+ "default": "Unknown Tier"
659
+ }
660
+ }
661
+ }
662
+ ```
663
+
664
+ ### **Array Concatenation**
665
+
666
+ `concat` joins the elements of an array-valued field into a single string.
667
+
668
+ ```json
669
+ {
670
+ "key": "tags",
671
+ "transform": { "concat": { "separator": ", " } }
672
+ }
673
+ ```
674
+
675
+ ### **Field Combination**
676
+
677
+ ### **Combining Multiple Fields**
678
+
679
+ ```json
680
+ {
681
+ "key": "full_name",
682
+ "transform": {
683
+ "combine_fields": {
684
+ "fields": ["first_name", "last_name"],
685
+ "separator": " "
686
+ }
687
+ }
688
+ }
689
+ ```
690
+
691
+ ```json
692
+ {
693
+ "key": "address_line",
694
+ "transform": {
695
+ "combine_fields": {
696
+ "fields": ["street", "city", "state"],
697
+ "template": "{street}, {city}, {state}"
698
+ }
699
+ }
700
+ }
701
+ ```
702
+
703
+ ### **Chained Transformations**
704
+
705
+ Apply multiple transformations in sequence:
706
+
707
+ ```json
708
+ {
709
+ "key": "name",
710
+ "alias": "formatted_name",
711
+ "transform": [
712
+ { "trim": true },
713
+ { "to_lowercase": true },
714
+ { "capitalize": true }
715
+ ]
716
+ }
717
+ ```
718
+
719
+ ---
720
+
721
+ ## **Field Validation**
722
+
723
+ Attach data-quality rules to a field with `validate`. Each field takes an ordered array of `{ rule, onFail }` entries; every value is checked against the rules and the `onFail` action decides what happens when a rule fails.
724
+
725
+ **Rules:** `min`, `max` (numeric bounds), `min_length`, `max_length` (string length), `regex` (pattern match), `required` (value must be present), `in` / `not_in` (allowed / disallowed value lists).
726
+
727
+ **`onFail` actions:** `fail` (abort the run), `skip` (drop the record), `warn` (log and keep), `set_default` (replace with the field's `default`).
728
+
729
+ ```json
730
+ {
731
+ "fields": [
732
+ {
733
+ "key": "email",
734
+ "validate": [
735
+ { "rule": { "required": true }, "onFail": "skip" },
736
+ { "rule": { "regex": "^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$" }, "onFail": "warn" }
737
+ ]
738
+ },
739
+ {
740
+ "key": "age",
741
+ "default": 0,
742
+ "validate": [
743
+ { "rule": { "min": 0 }, "onFail": "set_default" },
744
+ { "rule": { "max": 120 }, "onFail": "set_default" }
745
+ ]
746
+ }
747
+ ]
748
+ }
749
+ ```
750
+
751
+ ---
752
+
753
+ ## **Data Filtering**
754
+
755
+ Filters subset the consumer's result set before it is exported.
756
+
757
+ ### **Rule-Based Filters**
758
+
759
+ Filters are applied per record to the consumer result set, after the producers have been read and the
760
+ fields shaped. They work the same way for every source engine — SQL or file.
761
+
762
+ ```json
763
+ {
764
+ "filters": [
765
+ {
766
+ "rule": {
767
+ "member": "amount",
768
+ "operator": "greaterThan",
769
+ "values": ["1000"]
770
+ }
771
+ },
772
+ {
773
+ "rule": {
774
+ "member": "status",
775
+ "operator": "equals",
776
+ "values": ["Active"]
777
+ }
778
+ }
779
+ ]
780
+ }
781
+ ```
782
+
783
+ ### **Filter Operators**
784
+
785
+ | Operator | Description | Example |
786
+ | --- | --- | --- |
787
+ | `equals` | Exact match | `{"operator": "equals", "values": ["Active"]}` |
788
+ | `notEquals` | Not equal to | `{"operator": "notEquals", "values": ["Inactive"]}` |
789
+ | `contains` | String contains | `{"operator": "contains", "values": ["test"]}` |
790
+ | `notContains` | String doesn't contain | `{"operator": "notContains", "values": ["temp"]}` |
791
+ | `startsWith` | String starts with | `{"operator": "startsWith", "values": ["Mr."]}` |
792
+ | `endsWith` | String ends with | `{"operator": "endsWith", "values": [".com"]}` |
793
+ | `greaterThan` | Greater than | `{"operator": "greaterThan", "values": ["100"]}` |
794
+ | `greaterThanOrEquals` | Greater than or equal | `{"operator": "greaterThanOrEquals", "values": ["100"]}` |
795
+ | `lessThan` | Less than | `{"operator": "lessThan", "values": ["1000"]}` |
796
+ | `lessThanOrEquals` | Less than or equal | `{"operator": "lessThanOrEquals", "values": ["1000"]}` |
797
+ | `in` | Value in list | `{"operator": "in", "values": ["A", "B", "C"]}` |
798
+ | `notIn` | Value not in list | `{"operator": "notIn", "values": ["X", "Y", "Z"]}` |
799
+ | `between` | Value between range | `{"operator": "between", "values": ["100", "1000"]}` |
800
+ | `notBetween` | Value outside range | `{"operator": "notBetween", "values": ["100", "1000"]}` |
801
+ | `isNull` | Value is null | `{"operator": "isNull", "values": []}` |
802
+ | `isNotNull` | Value is not null | `{"operator": "isNotNull", "values": []}` |
803
+ | `true` | Boolean true | `{"operator": "true", "values": []}` |
804
+ | `false` | Boolean false | `{"operator": "false", "values": []}` |
805
+ | `matches` | Matches a regular expression | `{"operator": "matches", "values": ["^A.*Z$"]}` |
806
+ | `notMatches` | Does not match a regular expression | `{"operator": "notMatches", "values": ["^tmp"]}` |
807
+
808
+ ### **How Values Are Compared**
809
+
810
+ A filter's `values` are always written as strings, while a record holds the value its producer
811
+ dimension was cast to — a number for a `number` dimension, a boolean for a `boolean` one, a UTC ISO
812
+ timestamp for a `datetime` one. Each side is read as the type the record holds before being compared,
813
+ so the string in the configuration is not what makes the match fail:
814
+
815
+ - **Numbers** — `{"member": "amount", "operator": "equals", "values": ["150"]}` matches an `amount`
816
+ of `150`. A value that is not a number (`"abc"`) matches nothing.
817
+ - **Booleans** — `"true"`, `"false"`, `"1"` and `"0"` all match a `boolean` dimension. The dedicated
818
+ `true`/`false` operators take no values and do the same thing.
819
+ - **Dates** — a `datetime` is compared as an **instant**, not as text, so `"2024-03-01"` matches a
820
+ record holding `2024-03-01T00:00:00.000Z`, and a bound may carry its own offset
821
+ (`"2024-03-01T11:30:00+02:00"`). A bound with no offset is read as **UTC**, so a filter means the
822
+ same thing whatever the host's timezone. Only ISO-8601 bounds (`2024-03-01`, `2024-03-01T09:00:00`,
823
+ `2024-03-01T09:00:00Z`) are read as dates; anything else is compared as text. A `datetime`
824
+ dimension always holds an ISO timestamp whatever `format` it was read with, so filter it with an
825
+ ISO bound — `"20240301"` compares as text and matches nothing. A date kept as a `string` dimension
826
+ is text on both sides, and matches the spelling the column actually holds.
827
+ - A bound naming a **day** means that day's first instant, exactly as a SQL date literal does:
828
+ `{"operator": "between", "values": ["2024-03-01", "2024-03-31"]}` stops at the start of the 31st.
829
+ Write `"2024-03-31T23:59:59"` to include it.
830
+ - **Text operators** (`contains`, `startsWith`, `endsWith`, `matches`) read a non-string value's
831
+ text form — a number's digits, a date's ISO timestamp.
832
+ - A record whose value is **null** matches no comparison: `isNull`/`isNotNull` are how you filter on
833
+ it. The negations (`notEquals`, `notIn`, `notContains`, `notMatches`) keep such a record.
834
+
835
+ ### **Run Options (API and queue invocations)**
836
+
837
+ A consumer run started by the execute-consumer API or by the project's `API_QUEUE` may carry an
838
+ `options` object asking for **more filters** and a **record limit** on top of what the consumer
839
+ already declares. The invocation's filters are applied alongside the consumer's own, and the limit
840
+ caps the output records exactly as `remora run --limit` does.
841
+
842
+ ```json
843
+ {
844
+ "consumer": "c_sales_export",
845
+ "options": {
846
+ "limit": 100,
847
+ "filters": [
848
+ {
849
+ "rule": {
850
+ "member": "region",
851
+ "operator": "in",
852
+ "values": ["EMEA", "APAC"]
853
+ }
854
+ }
855
+ ]
856
+ }
857
+ }
858
+ ```
859
+
860
+ The options are checked against the named consumer **before anything is read from a source**, so a
861
+ malformed request costs nothing and is answered by the caller's own mistake rather than by a failure
862
+ from the middle of a run. The HTTP API answers **400** with the reason; a queue command fails and
863
+ follows the `deadLetterQueue` rule for the queue it arrived on.
864
+
865
+ | Option | Rule |
866
+ | --- | --- |
867
+ | `filters` | An array of filter objects. At most 100 filters, nested through `and` / `or` at most 5 levels deep |
868
+ | `filters[].rule.member` | Must name a field the consumer actually **outputs** — the final name after `alias`, which is what a record holds when filters run. An unknown name is refused rather than silently matching nothing |
869
+ | `filters[].rule.operator` | One of the [filter operators](#filter-operators) |
870
+ | `filters[].rule.values` | An array of strings, of the arity the operator needs: `between` exactly two, `in` / `notIn` at least one, `isNull` / `isNotNull` / `true` / `false` none |
871
+ | `limit` | A whole number from 1 to 10 000 000 — a number, not a string |
872
+
873
+ Every rejection names the option and where in the tree it sits, for example
874
+ `Invalid filter at filters[1].or[0]: consumer "c_sales" has no field named "regionn"`.
875
+
876
+ **Refused outright:**
877
+
878
+ - `offset` and `order` — no stage of a run implements them, so accepting them would mean answering
879
+ with unskipped, unordered data as though the request had been honoured
880
+ - the `sql` filter operator, which is not evaluated against records
881
+ - any other key. An unknown option (`limits`, `filter`) is a typo that would otherwise be ignored in
882
+ silence, so it is refused instead
883
+
884
+ ### **Pushdown (Delta Share)**
885
+
886
+ Filters are always applied per record, but a source that can narrow the read for itself is *also*
887
+ given them as a query hint, so it hands back less than the whole table. Today that is **Delta
888
+ Share**: the eligible filters go out as `jsonPredicateHints` on the table query and the record limit
889
+ as `limitHint`, and the share server skips the files that cannot hold a matching row or that fall
890
+ past the rows being asked for. Nothing about the result changes — the per-record pass runs either
891
+ way — so this only ever saves download time and staging space.
892
+
893
+ A filter is pushed only when it can mean exactly the same thing at the source:
894
+
895
+ - The field reads a producer dimension **untouched**. A field with a `transform`, a `validate`, a
896
+ `default`, or one that is `fixed`/`copyFrom`/`lineCount`, is never pushed, and neither is a masked
897
+ dimension or one filled in after the read (`sourceFilename`).
898
+ - The operator has a counterpart in the Delta Sharing predicate grammar: `equals`, `in`, `between`
899
+ and the four `greaterThan`/`lessThan` comparisons. The substring and regex operators
900
+ (`contains`, `startsWith`, `matches`, …) have none and stay local.
901
+ - The filter does not also keep rows whose value is **missing**. A value the source doesn't have
902
+ reads back as `""`/`0`, so `notEquals`, `isNotNull`, `notBetween` and any range covering `0`
903
+ keep those rows — a source pruning on them would drop data — and none of them is pushed.
904
+ - Date bounds are ISO-8601 (`2024-01-01`, or `2024-01-01T00:00:00Z`). A date in a producer's own
905
+ `format` is not translated, and a bound carrying milliseconds is left local rather than rounded.
906
+ - Filters supplied by the invocation (an API or queue request) are pushed on the same terms as the
907
+ ones written in the consumer.
908
+
909
+ Boolean columns are never pushed.
910
+
911
+ The **record limit** (`remora run -l`, or `limit` on an API/queue request) is pushed on a stricter
912
+ rule, because a bounded read is only sound when every row read reaches the output. It is dropped
913
+ whenever anything between the source and the export can *drop* a record: any filter at all (a
914
+ bounded read could have fetched exactly the rows the filter rejects), `distinct`, `distinctOn`,
915
+ `pivot`, a field validation set to `"onFail": "skip"`, a producer `recordSelector`, or a group that
916
+ fans a line out to rows. A transformation's own `"onError": "skip"` does *not* — it abandons that one
917
+ transformation, not the record.
918
+
919
+ Set `"disablePushdown": true` in a **producer's** settings to turn both hints off for that producer —
920
+ the filters and the limit keep working, the whole table is just read first.
921
+
922
+ ### **Complex Filter Examples**
923
+
924
+ ### **Multiple Conditions**
925
+
926
+ ```json
927
+ {
928
+ "filters": [
929
+ {
930
+ "rule": {
931
+ "member": "amount",
932
+ "operator": "greaterThan",
933
+ "values": ["100"]
934
+ }
935
+ },
936
+ {
937
+ "rule": {
938
+ "member": "status",
939
+ "operator": "in",
940
+ "values": ["Active", "Pending"]
941
+ }
942
+ }
943
+ ]
944
+ }
945
+ ```
946
+
947
+ ### **Date Range Filtering**
948
+
949
+ ```json
950
+ {
951
+ "filters": [
952
+ {
953
+ "rule": {
954
+ "member": "order_date",
955
+ "operator": "between",
956
+ "values": ["2024-01-01", "2024-12-31"]
957
+ }
958
+ }
959
+ ]
960
+ }
961
+ ```
962
+
963
+ ---
964
+
965
+ ## **Output Configuration**
966
+
967
+ Outputs define where and how the processed data is exported. A consumer declares **exactly one** —
968
+ `outputs` stays an array, but a second entry is refused at `compile`. A run stages its records in one
969
+ encoding and *moves* that file to the destination, so there has never been anything left for a second
970
+ output to write: only the first was ever exported. To write the same data twice, declare a second
971
+ consumer over the same producers.
972
+
973
+ ### **Output Properties**
974
+
975
+ | Property | Required | Description | Example |
976
+ | --- | --- | --- | --- |
977
+ | `format` | ✅ | Output format | `"JSON"`, `"CSV"`, `"TXT"`, `"FIXED"`, `"PARQUET"`, `"API"` |
978
+ | `exportDestination` | ❌ | Target destination source | `"s3-bucket"`, `"local-output"` |
979
+ | `exportName` | ❌ | Fixed filename for exports | `"daily-report"`, `"customer-data"` |
980
+ | `delimiter` | ❌ | Field delimiter, only used for `TXT` output (defaults to a tab). Ignored by every other format, `PARQUET` included, and rejected on `FIXED`, which has no delimiter at all | `"\t"`, `";"` |
981
+ | `header` | ❌ | `CSV`/`TXT` only: write the field names as the first line of each exported file (see [The Header Line](#the-header-line)). Rejected on `PARQUET`, which carries its own schema, and on `FIXED`, which has no room for one | `true` (default), `false` |
982
+ | `omitNull` | ❌ | `JSON`/`API` only: leave out the fields whose value is `null` (see [Omitting Null Fields](#omitting-null-fields)) | `true`, `false` (default) |
983
+ | `recordLength` | ❌ | `FIXED` only: the declared length of one record, which `compile` checks the field widths sum to (see [FIXED Output](#fixed-output)) | `196` |
984
+ | `defaults` | ❌ | `FIXED` only: the fill rules every field of one dimension type takes unless its own `position` overrides them | `{ "number": { "pad": "0" } }` |
985
+ | `lineEnding` | ❌ | `FIXED` only: what ends every record, the last one included | `"lf"` (default), `"crlf"` |
986
+ | `layouts` | ❌ | `FIXED` only: several record layouts in one file, picked per record by the value of one field (see [Several Record Layouts in One File](#several-record-layouts-in-one-file)) | Layout map |
987
+ | `trigger` | ❌ | Automated execution triggers | Trigger configuration |
988
+ | `onSuccess` | ❌ | Actions to perform on successful export | Action array |
989
+ | `onError` | ❌ | Actions to perform on export error | Action array |
990
+
991
+ ### **Output Formats**
992
+
993
+ ### **JSON Output**
994
+
995
+ ```json
996
+ {
997
+ "outputs": [
998
+ {
999
+ "format": "JSON",
1000
+ "exportDestination": "s3-bucket",
1001
+ "exportName": "customer-analysis"
1002
+ }
1003
+ ]
1004
+ }
1005
+ ```
1006
+
1007
+ ### **Omitting Null Fields**
1008
+
1009
+ A record normally carries every field, including the ones with no value (`"addr2Street": null`). On a wide, sparse record — a positional layout with 1,300 fields of which a few dozen are populated — that is most of the payload. `omitNull` leaves those keys out entirely:
1010
+
1011
+ ```json
1012
+ {
1013
+ "outputs": [
1014
+ {
1015
+ "format": "JSON",
1016
+ "exportDestination": "s3-bucket",
1017
+ "exportName": "member-detail",
1018
+ "omitNull": true
1019
+ }
1020
+ ]
1021
+ }
1022
+ ```
1023
+
1024
+ Records then legitimately differ in shape from one another, which is why it applies to `JSON` and `API` only:
1025
+
1026
+ - On `CSV`/`TXT`/`FIXED`/`PARQUET` it is **rejected**, not ignored — a positional column can't be missing from a row, and a parquet row group declares every column up front.
1027
+ - Only `null` is dropped. An empty string, a `0` and a `false` are values, and are exported.
1028
+
1029
+ ### **CSV Output**
1030
+
1031
+ ```json
1032
+ {
1033
+ "outputs": [
1034
+ {
1035
+ "format": "CSV",
1036
+ "exportDestination": "local-output",
1037
+ "exportName": "sales-report"
1038
+ }
1039
+ ]
1040
+ }
1041
+ ```
1042
+
1043
+ ### **The Header Line**
1044
+
1045
+ A `CSV` or `TXT` file is positional: it carries values and nothing that names them. So both formats write a **header line** first — the exported field names, in column order, quoted and delimited exactly as the rows under them, so the header parses with the same reader the data does:
1046
+
1047
+ ```
1048
+ "id","primary_email","plan","country"
1049
+ "USR-00001","alice.rossi@example.com","free","Italy"
1050
+ ```
1051
+
1052
+ The names are the exported ones: a field's `alias` where it has one, its `key` otherwise. A `hidden` field is not written, so it is not in the header either. With a `split`, **every** file of the output carries the header, not just the first one — each file has to stand on its own.
1053
+
1054
+ Set `header` to `false` where the destination reads the first line as data:
1055
+
1056
+ ```json
1057
+ {
1058
+ "outputs": [
1059
+ {
1060
+ "format": "CSV",
1061
+ "exportDestination": "local-output",
1062
+ "exportName": "sales-report",
1063
+ "header": false
1064
+ }
1065
+ ]
1066
+ }
1067
+ ```
1068
+
1069
+ It only applies to `CSV` and `TXT`. On a `JSON` or `API` output each record already carries its own field names, a `PARQUET` file carries its own schema, and a `FIXED` line has no room for a name that would not sit on the columns it describes — so `header` is **rejected** at compile on all four rather than ignored.
1070
+
1071
+ ### **How a value is written**
1072
+
1073
+ A `CSV`/`TXT` file writes every value quoted, and that leaves the unquoted empty field free to mean
1074
+ something: it is a **null**. An empty string is `""`. The two are different values, and on a
1075
+ positional line this is the only way to tell them apart:
1076
+
1077
+ ```
1078
+ "id","note","amount"
1079
+ "USR-1","","12.5" <- note is the empty string
1080
+ "USR-2",, <- note and amount are null
1081
+ ```
1082
+
1083
+ A reader that treats an unquoted empty field as "no value" — which most CSV readers do, and which is
1084
+ what `COPY`, `LOAD DATA` and a pandas `read_csv` all assume — gets a null back. A reader that does
1085
+ not sees an empty string, exactly as before.
1086
+
1087
+ A value holding a **newline** or a carriage return is written as the two characters `\n` or `\r`.
1088
+ A line of a remora file is one record, everywhere in the pipeline, so a value carrying a real
1089
+ newline would otherwise arrive as two broken rows. The escape is one-way: nothing turns it back into
1090
+ a newline, and an ordinary backslash in your data (`C:\path`) is never touched. Use `JSON` or
1091
+ `PARQUET` where a value must carry a newline verbatim — both hold one natively.
1092
+
1093
+ ### **TXT Output**
1094
+
1095
+ Delimited text export. The `delimiter` is configurable and defaults to a tab if omitted:
1096
+
1097
+ ```json
1098
+ {
1099
+ "outputs": [
1100
+ {
1101
+ "format": "TXT",
1102
+ "exportDestination": "local-output",
1103
+ "exportName": "sales-report",
1104
+ "delimiter": "|"
1105
+ }
1106
+ ]
1107
+ }
1108
+ ```
1109
+
1110
+ ### **FIXED Output**
1111
+
1112
+ A fixed-width (**positional**) flat file: no delimiter, no quoting, no header line. Every field occupies the same columns on every record, and each value is filled out to the width its layout gives it. This is the write side of the `FIXED` producer (see the Producers guide) — what you export when the receiver reads by column rather than by field name: a mainframe feed, a clearing-house submission, a banking return file.
1113
+
1114
+ Each visible field declares a `position`, and the output declares how long a record is:
1115
+
1116
+ ```json
1117
+ {
1118
+ "fields": [
1119
+ { "key": "member_id", "position": { "width": 12, "start": 1 } },
1120
+ { "key": "service_date", "position": { "width": 8, "start": 13 } },
1121
+ { "key": "amount", "position": { "width": 10, "start": 21 } }
1122
+ ],
1123
+ "outputs": [
1124
+ {
1125
+ "format": "FIXED",
1126
+ "exportDestination": "local-output",
1127
+ "exportName": "claims",
1128
+ "recordLength": 30
1129
+ }
1130
+ ]
1131
+ }
1132
+ ```
1133
+
1134
+ ```
1135
+ MBR000000123202604130000123.45
1136
+ ```
1137
+
1138
+ The file is exported with a `.txt` extension, to any file destination (local, S3, a pre-signed URL), with the same naming, [splitting](#output-splitting), trigger and `onSuccess` behaviour as every other file output.
1139
+
1140
+ ### **How a Value Fills Its Field**
1141
+
1142
+ | Property | Required | Description | Example |
1143
+ | --- | --- | --- | --- |
1144
+ | `position.width` | ✅ | How many characters the field occupies on the line | `10` |
1145
+ | `position.align` | ❌ | Which end of the field the value sits at, the other end taking the padding. Defaults by type: `left` for a `string`, `datetime` or `boolean`, `right` for a `number` | `"left"`, `"right"` |
1146
+ | `position.pad` | ❌ | The single character the rest of the field is filled with. Defaults by type: a space for a `string`, `datetime` or `boolean`, `0` for a `number` | `" "`, `"0"` |
1147
+ | `position.nullFill` | ❌ | The single character a **null** field is filled with, for its full width. Defaults to a space for every type | `" "`, `"0"` |
1148
+ | `position.overflow` | ❌ | What a value longer than `width` does | `"fail"` (default), `"truncate"` |
1149
+ | `position.start` | ❌ | 1-based column the field is expected to start at. An assertion, not a second source of truth (see below) | `21` |
1150
+
1151
+ Five rules decide what lands on the line:
1152
+
1153
+ | Case | What is written |
1154
+ | --- | --- |
1155
+ | Value shorter than `width` | Padded out to `width` with `pad`, on the side `align` leaves free |
1156
+ | Value exactly `width` | The value |
1157
+ | Value longer than `width` | **The run fails**, naming the field, the value and both widths — unless `overflow` is `"truncate"` |
1158
+ | `null` | The full width in `nullFill` |
1159
+ | Empty string | The same as a `null`. The staged encoding keeps the two apart; a positional line cannot |
1160
+
1161
+ **Overflow fails rather than truncates.** A truncated account number is a plausible-looking account number, and a feed nobody re-reads is exactly where that ships unnoticed. `"overflow": "truncate"` exists per field for the layouts that genuinely specify a cut, and the end that survives follows `align`: a left-aligned value keeps its head, a right-aligned one keeps its low-order tail, which is what a COBOL `MOVE` into a shorter field does.
1162
+
1163
+ **A null is filled with spaces, not zeros, even on a number.** Zero-filling an empty numeric field writes `00`, which the receiver reads as the number zero — a real value where the record carried none. Set `nullFill` on the field, or per type in `defaults`, for a receiver that needs something else.
1164
+
1165
+ **Carry a positional field through as a `string`.** A `number` dimension drops leading zeros — `"00"` read as a number is `0` — so writing it back takes `"align": "right"` with `"pad": "0"`, a rule you have to get right field by field. Declaring the producer dimension a `string` instead carries the characters through untouched.
1166
+
1167
+ ### **Column Order, Gaps and `start`**
1168
+
1169
+ **Column order is field order, and every offset is computed from the widths before it.** No column number is ever transcribed by hand: list the fields in the order the record is written, give each one a width, and the layout falls out of them. That is the same rule the read side follows, and it is why a layout is written once rather than renumbered every time a field moves.
1170
+
1171
+ Gaps and COBOL `FILLER` need no concept of their own — a [fixed field](#fixed-fields) with a width is blank columns:
1172
+
1173
+ ```json
1174
+ { "key": "filler", "fixed": true, "default": "", "position": { "width": 103 } }
1175
+ ```
1176
+
1177
+ **`position.start` and `recordLength` are checksums.** Where you write them, `compile` recomputes them from the widths and fails if they disagree. A layout spec gives you the widths, the start columns *and* the record length, and declaring all three is what catches a field you left out: a missing 8-character filler shifts every field after it, and nothing about the resulting file looks wrong.
1178
+
1179
+ `recordLength` is the length of the record **as written**, not the length of the copybook it came from. Where a 256-column layout is cut after the last field that carries anything, the fields past the cut are simply not declared — not padded to. Every record is then exactly `recordLength` characters, a record whose last field is null included: that field is written full width in `nullFill` and the line is never cut short.
1180
+
1181
+ `remora compile` prints the resolved layout, so you check it against the spec once instead of counting characters:
1182
+
1183
+ ```
1184
+ FIXED layout of consumer "claims_export"
1185
+ record: 30 characters, 3 field(s)
1186
+ start end width field
1187
+ 1 12 12 member_id
1188
+ 13 20 8 service_date
1189
+ 21 30 10 amount
1190
+ ```
1191
+
1192
+ Every problem with a layout is reported in one `compile` rather than one per run, so six fields missing a width come back as six lines. `run` and `verify` resolve the same layout before they read a producer, so a layout that does not add up fails in front of the run instead of after the whole dataset has been built.
1193
+
1194
+ ### **Fill Rules by Type**
1195
+
1196
+ A wide layout states a rule once in `defaults` instead of on each of ninety fields, keyed by the field's dimension type:
1197
+
1198
+ ```json
1199
+ {
1200
+ "outputs": [
1201
+ {
1202
+ "format": "FIXED",
1203
+ "exportDestination": "local-output",
1204
+ "recordLength": 256,
1205
+ "defaults": {
1206
+ "number": { "nullFill": "0" },
1207
+ "datetime": { "align": "right", "pad": "0" }
1208
+ }
1209
+ }
1210
+ ]
1211
+ }
1212
+ ```
1213
+
1214
+ Above: a receiver that reads a blank numeric field as an error gets `0` there instead, and dates written as bare digits are right-aligned and zero-padded like the numbers they look like.
1215
+
1216
+ Each of `align`, `pad`, `nullFill` and `overflow` resolves on its own, nearest declaration first: the field's own `position`, then the layout's `defaults`, then the output's `defaults`, then the type's built-in rule. A field that sets only `align` keeps the `pad` the defaults gave it.
1217
+
1218
+ ### **Several Record Layouts in One File**
1219
+
1220
+ A positional feed often carries more than one kind of record in one file — a 44-character transaction and a 196-character one, told apart by a marker on the line. `layouts` is the write-side mirror of a producer's `recordSelector`: it names the field whose value picks the layout, and what each value writes.
1221
+
1222
+ ```json
1223
+ {
1224
+ "outputs": [
1225
+ {
1226
+ "format": "FIXED",
1227
+ "exportDestination": "local-output",
1228
+ "exportName": "pod_return",
1229
+ "lineEnding": "crlf",
1230
+ "layouts": {
1231
+ "discriminator": "key_tran_id",
1232
+ "order": ["1300", "1200"],
1233
+ "values": {
1234
+ "1300": {
1235
+ "fields": ["key_serv_plan", "key_tran_id", "key_prov_id", "main_user"],
1236
+ "recordLength": 44
1237
+ },
1238
+ "1200": {
1239
+ "fields": ["key_serv_plan", "key_tran_id", "key_prov_id", "key_version", "main_user", "sort_date"],
1240
+ "recordLength": 196
1241
+ }
1242
+ }
1243
+ }
1244
+ }
1245
+ ]
1246
+ }
1247
+ ```
1248
+
1249
+ | Property | Required | Description |
1250
+ | --- | --- | --- |
1251
+ | `discriminator` | ✅ | The consumer field whose value picks the layout, by the name it is exported under. It does not have to be written by any of the layouts, though it usually is |
1252
+ | `order` | ✅ | The discriminator values in the order their record blocks are written. The complete list: a record carrying a value that is not here has no block to go in and **fails the run**, naming the value |
1253
+ | `values` | ✅ | One entry per value in `order`: the `fields` it writes in column order, plus its own `recordLength` and `defaults` |
1254
+
1255
+ How it behaves:
1256
+
1257
+ - **The records come out grouped**, one contiguous block per value of `order` — which is how a real positional feed is written. Within a block they keep the order they were read in; Remora has no output sort, so the order inside a block is the order the producers delivered. With a [`split`](#output-splitting), each file is grouped on its own.
1258
+ - The consumer's `fields` is the **union** of every layout's, and each layout names the subset its records carry, in its own column order. So one field can sit at different columns in different layouts — and that field then declares a `width` and no `start`, because a `start` is a column on one line and that field sits on several.
1259
+ - A field the consumer declares that no layout writes needs no `position`.
1260
+ - A `recordLength` on the output beside `layouts` is **rejected**: a multi-layout file has no single record length. Declare one per layout.
1261
+ - At most **64** layouts in one output.
1262
+ - Where the record types come from different producers, `union` still requires them to share their output dimensions: each producer declares the superset of fields and leaves the ones its layout has no field for empty.
1263
+
1264
+ ### **How a Record Ends**
1265
+
1266
+ `lineEnding` is `"lf"` (the default) or `"crlf"`, and it ends **every** record, the last one included. A real positional feed picks one and holds it: NASCO POD files, for instance, are CRLF throughout.
1267
+
1268
+ A file with **no terminator at all** — fixed record framing, `RECFM=FB`, where the record length alone says where one record ends — is not expressible here, and asking for it is the signal that what is needed is byte-mode framing rather than a line-based file. Remora does not write one.
1269
+
1270
+ ### **What the Receiver Has To Do**
1271
+
1272
+ The file Remora writes is **text, encoded UTF-8, one character per column**. For the layouts this was built for that is plain ASCII and the distinction never comes up — but a non-ASCII character (`é`, a typographic quote) is one column and more than one byte, so the record stays `recordLength` *characters* while the line stops being `recordLength` *bytes*. Where the receiver counts bytes, keep the data ASCII: a [`replace`](#pattern-replacement) transform on the field is the place to force it.
1273
+
1274
+ Two conversions a mainframe target needs are the receiver's, not Remora's, and are the mirror image of the ones the same shops already run on data coming the other way:
1275
+
1276
+ - **ASCII → EBCDIC.** A z/OS job reads EBCDIC; this file leaves as ASCII.
1277
+ - **Reframing to `RECFM=FB`.** This file leaves as line-terminated records; a fixed-block dataset carries no terminators at all.
1278
+
1279
+ Both are normally one step of the transfer job that delivers the file, not something the layout can express.
1280
+
1281
+ ### **What a FIXED Output Cannot Declare**
1282
+
1283
+ Each of these is **rejected** at `compile` rather than ignored: silently dropping a rule you wrote is how a file ships looking almost right.
1284
+
1285
+ | Declaration | Why |
1286
+ | --- | --- |
1287
+ | `header` | A positional line has no room for one, and a field name would not sit on the columns it describes |
1288
+ | `delimiter` | A positional line has none: its fields are found by the columns they occupy |
1289
+ | `omitNull` | A column can't be missing from a positional row. A null is written as `nullFill`, full width |
1290
+ | `"key": "*"` | Every field of a positional line declares its own `width`, so the field list has to be explicit. A [pattern key](#pattern-keys) is allowed, but every column it matches takes the same `position` |
1291
+
1292
+ The reverse is rejected too: `recordLength`, `defaults`, `lineEnding` and `layouts` on a `CSV`, `TXT`, `JSON`, `PARQUET` or `API` output, and a field `position` on a consumer whose output is not `FIXED`.
1293
+
1294
+ ### **PARQUET Output**
1295
+
1296
+ Apache Parquet: a typed, SNAPPY-compressed **columnar** file, which is what a data lake expects to read. It writes to any file destination (local, S3, a pre-signed URL) with the same naming, splitting and trigger behaviour as every other file output:
1297
+
1298
+ ```json
1299
+ {
1300
+ "outputs": [
1301
+ {
1302
+ "format": "PARQUET",
1303
+ "exportDestination": "lake",
1304
+ "exportName": "sales"
1305
+ }
1306
+ ]
1307
+ }
1308
+ ```
1309
+
1310
+ Two things it gives a reader that a `CSV` export cannot. The file **declares its types**, so nothing downstream has to guess them back out of the text. And it is much smaller: a four-column, 2-million-row dataset is about 28 MB as parquet against 115 MB as quoted CSV.
1311
+
1312
+ The schema comes from what the consumer already declares — each field's dimension type:
1313
+
1314
+ | Dimension type | Parquet column |
1315
+ | --- | --- |
1316
+ | `string` | `BYTE_ARRAY` / UTF8 |
1317
+ | `number` | `DOUBLE` — every value remora holds is a double, so an integer column would promise precision the record never had |
1318
+ | `boolean` | `BOOLEAN` |
1319
+ | `datetime` | `INT64` / `TIMESTAMP_MILLIS` |
1320
+ | *none* (a `fixed`, `lineCount` or `copyFrom` field, or a column matched by a pattern key) | `BYTE_ARRAY` / UTF8 — there is no declared type to read |
1321
+
1322
+ Every column is nullable, so **a null stays a null** rather than becoming `0`, `false` or `""` — in a column store those are different things. A `hidden` field is not in the file at all, not even as an empty column.
1323
+
1324
+ `header` is **rejected** on a parquet output: the file already carries its column names in its schema, and a header line would be a first row of data claiming to be one. `delimiter` means nothing to it and is ignored.
1325
+
1326
+ A parquet file is a container whose footer can only be written once the last row group is encoded, so it is built in full and then shipped, rather than streamed out a line at a time. One consequence is worth knowing about if you use `split`: **`split.maxFileSizeGb` caps the internal staged bytes, not the parquet bytes**, so the exported files come out comfortably *under* the cap rather than at it. See [Splitting the Output](#splitting-the-output).
1327
+
1328
+ ### **API Output**
1329
+
1330
+ ```json
1331
+ {
1332
+ "outputs": [
1333
+ {
1334
+ "format": "API"
1335
+ }
1336
+ ]
1337
+ }
1338
+ ```
1339
+
1340
+ ### **Multiple Outputs**
1341
+
1342
+ Export to multiple destinations simultaneously:
1343
+
1344
+ ```json
1345
+ {
1346
+ "outputs": [
1347
+ {
1348
+ "format": "JSON",
1349
+ "exportDestination": "s3-archive"
1350
+ },
1351
+ {
1352
+ "format": "CSV",
1353
+ "exportDestination": "local-reports"
1354
+ },
1355
+ {
1356
+ "format": "API"
1357
+ }
1358
+ ]
1359
+ }
1360
+ ```
1361
+
1362
+ ---
1363
+
1364
+ ## **Automated Triggers**
1365
+
1366
+ Configure automated execution of consumers based on schedules, API calls, or queue messages.
1367
+
1368
+ ### **Trigger Types**
1369
+
1370
+ ### **CRON Triggers**
1371
+
1372
+ ```json
1373
+ {
1374
+ "outputs": [
1375
+ {
1376
+ "format": "JSON",
1377
+ "exportDestination": "daily-exports",
1378
+ "trigger": {
1379
+ "type": "CRON",
1380
+ "value": "0 2 * * *",
1381
+ "metadata": {
1382
+ "timezone": "UTC",
1383
+ "description": "Daily at 2 AM"
1384
+ }
1385
+ }
1386
+ }
1387
+ ]
1388
+ }
1389
+ ```
1390
+
1391
+ ### **API Triggers**
1392
+
1393
+ ```json
1394
+ {
1395
+ "outputs": [
1396
+ {
1397
+ "format": "JSON",
1398
+ "exportDestination": "api-exports",
1399
+ "trigger": {
1400
+ "type": "API",
1401
+ "value": "/api/export/customer-data",
1402
+ "metadata": {
1403
+ "method": "POST",
1404
+ "auth_required": "true"
1405
+ }
1406
+ }
1407
+ }
1408
+ ]
1409
+ }
1410
+ ```
1411
+
1412
+ ### **Queue Triggers**
1413
+
1414
+ ```json
1415
+ {
1416
+ "outputs": [
1417
+ {
1418
+ "format": "JSON",
1419
+ "exportDestination": "queue-exports",
1420
+ "trigger": {
1421
+ "type": "QUEUE",
1422
+ "value": "https://sqs.us-east-1.amazonaws.com/123456789012/data-processing-queue",
1423
+ "metadata": {
1424
+ "region": "us-east-1",
1425
+ "messageType": "data-update",
1426
+ "accessKeyId": "{AWS_ACCESS_KEY_ID}",
1427
+ "secretAccessKey": "{AWS_SECRET_ACCESS_KEY}"
1428
+ }
1429
+ }
1430
+ }
1431
+ ]
1432
+ }
1433
+ ```
1434
+
1435
+ **QUEUE trigger metadata:**
1436
+
1437
+ | Field | Description |
1438
+ | --- | --- |
1439
+ | `region` | AWS region of the queue |
1440
+ | `accountId` | AWS account ID — used to build the full queue URL when `value` is only a queue name |
1441
+ | `messageType` | Only messages whose `type`/`messageType`/`eventType` matches are processed by this consumer; others are left on the queue. Enables one shared queue across multiple consumers |
1442
+ | `method` | Credential method: `access-secret-key` (default, static keys), `arn` (assume an IAM role via STS), or `implicit`/`iam` (AWS SDK default chain — instance/task role) |
1443
+ | `accessKeyId`, `secretAccessKey`, `sessionToken` | Static credentials (for `access-secret-key`) |
1444
+ | `roleArn`, `roleSessionName`, `externalId` | STS AssumeRole settings (for `method: "arn"`) |
1445
+ | `deadLetterQueue` | Queue URL or name where a repeatedly failing message is parked (see [Failed messages](#failed-messages)) |
1446
+ | `maxReceiveCount` | Failed deliveries tolerated before the message stops being processed. Defaults to `"5"` |
1447
+
1448
+ #### **Failed messages**
1449
+
1450
+ A message is only deleted from the queue once at least one consumer has processed it successfully —
1451
+ which is what keeps a shared queue usable. When every consumer that picked it up failed, the message
1452
+ stays in the queue and SQS redelivers it after the visibility timeout (5 minutes).
1453
+
1454
+ That is the right behaviour for a transient failure and the wrong one for a message that can never
1455
+ succeed — a malformed payload, a consumer whose source is gone. Such a message would trigger a full
1456
+ consumer run every five minutes until the queue's retention period (4 days by default) silently
1457
+ discards it: roughly a thousand failed runs.
1458
+
1459
+ So the run is bounded. Once SQS has delivered the message `maxReceiveCount` times and it has failed
1460
+ each time, it stops being processed. What happens next depends on `deadLetterQueue`:
1461
+
1462
+ | `deadLetterQueue` | What happens to the exhausted message |
1463
+ | --- | --- |
1464
+ | Set | Copied to that queue, then deleted from this one. The copy carries `remoraSourceQueue`, `remoraSourceMessageId` and `remoraReceiveCount` as message attributes, so it can be diagnosed and replayed |
1465
+ | Not set | Skipped on every later delivery, without running any consumer. Nothing is deleted — the message stays in the queue for inspection and expires with the queue's retention period |
1466
+
1467
+ ```json
1468
+ {
1469
+ "trigger": {
1470
+ "type": "QUEUE",
1471
+ "value": "orders-queue",
1472
+ "metadata": {
1473
+ "region": "us-east-1",
1474
+ "accountId": "123456789012",
1475
+ "messageType": "data-update",
1476
+ "deadLetterQueue": "orders-queue-dlq",
1477
+ "maxReceiveCount": "3"
1478
+ }
1479
+ }
1480
+ }
1481
+ ```
1482
+
1483
+ A dead-letter queue is worth configuring: without one the failure is bounded but invisible — the
1484
+ message is skipped, and the only trace is the worker's log and the `quarantinedCount` it reports.
1485
+ An SQS redrive policy on the queue itself does the same job, and applies to every system reading
1486
+ that queue rather than only to Remora.
1487
+
1488
+ Two things are deliberately *not* treated as failures. A message no consumer **claimed** — its
1489
+ `messageType` matched nobody — is left on the queue untouched for other systems, however many times
1490
+ it is redelivered. And the skip list is held in the worker process only, so a restart gives every
1491
+ message another chance, which is usually what should happen right after a fix is deployed.
1492
+
1493
+ None of this applies to the project's `API_QUEUE`, whose messages are commands rather than events: a
1494
+ failed command is never retried, it leaves the queue on its first delivery. See
1495
+ [As the execute-consumer API queue](Remora%20-%20Sources.md).
1496
+
1497
+ The delivery count itself comes from SQS, not from Remora, so it survives restarts and is shared
1498
+ across every worker polling the queue.
1499
+
1500
+ Example authenticating via an assumed IAM role instead of static keys:
1501
+
1502
+ ```json
1503
+ {
1504
+ "trigger": {
1505
+ "type": "QUEUE",
1506
+ "value": "orders-queue",
1507
+ "metadata": {
1508
+ "region": "us-east-1",
1509
+ "accountId": "123456789012",
1510
+ "method": "arn",
1511
+ "roleArn": "arn:aws:iam::123456789012:role/remora-sqs",
1512
+ "messageType": "data-update"
1513
+ }
1514
+ }
1515
+ }
1516
+ ```
1517
+
1518
+ ### **Post-Processing Actions**
1519
+
1520
+ ### **File Movement**
1521
+
1522
+ ```json
1523
+ {
1524
+ "outputs": [
1525
+ {
1526
+ "format": "CSV",
1527
+ "exportDestination": "processing-folder",
1528
+ "onSuccess": [
1529
+ {
1530
+ "action": "move-file",
1531
+ "moveToDestination": "archive-folder"
1532
+ }
1533
+ ],
1534
+ "onError": [
1535
+ {
1536
+ "action": "move-file",
1537
+ "moveToDestination": "error-folder"
1538
+ }
1539
+ ]
1540
+ }
1541
+ ]
1542
+ }
1543
+ ```
1544
+
1545
+ ### **Run Consumer(s)**
1546
+
1547
+ The `run-consumer` action triggers one or more other consumers once the output finishes — on the success path (`onSuccess`) or the failure path (`onError`). Listed consumers run **sequentially** in declared order as fresh full runs. A downstream failure is **logged and swallowed**: it does not change the parent consumer's result or stop the rest of the chain.
1548
+
1549
+ ```json
1550
+ {
1551
+ "outputs": [
1552
+ {
1553
+ "format": "JSON",
1554
+ "exportDestination": "processing-folder",
1555
+ "onSuccess": [
1556
+ {
1557
+ "action": "run-consumer",
1558
+ "consumers": ["c_downstream_report", "c_notify"]
1559
+ }
1560
+ ],
1561
+ "onError": [
1562
+ {
1563
+ "action": "run-consumer",
1564
+ "consumers": ["c_alert"]
1565
+ }
1566
+ ]
1567
+ }
1568
+ ]
1569
+ }
1570
+ ```
1571
+
1572
+ Each name in `consumers` must resolve to a consumer in the project (validated at compile time). Chains are guarded against cycles (e.g. `A → B → A` is detected and skipped) and capped at a maximum depth (`MAX_ON_FINISH_DEPTH`).
1573
+
1574
+ ---
1575
+
1576
+ ## **Consumer Options**
1577
+
1578
+ Additional processing options for consumers.
1579
+
1580
+ ### **Options Properties**
1581
+
1582
+ | Property | Description | Example |
1583
+ | --- | --- | --- |
1584
+ | `distinct` | Return only unique records | `true`, `false` |
1585
+ | `distinctOn` | De-duplicate on specific key(s), keeping one record per key using a resolution rule | See below |
1586
+ | `pivot` | Turn row values into columns with an aggregation | See below |
1587
+
1588
+ ```json
1589
+ {
1590
+ "options": {
1591
+ "distinct": true}
1592
+ }
1593
+ ```
1594
+
1595
+ ### **Distinct On**
1596
+
1597
+ `distinctOn` de-duplicates on one or more `keys` and, when duplicates collide, keeps a single record according to `resolution`. Strategies: `first`/`last` (by `orderBy`, with an optional `direction` of `asc`/`desc`), or `min`/`max` (of `orderBy`). Reference fields by their alias if set.
1598
+
1599
+ ```json
1600
+ {
1601
+ "options": {
1602
+ "distinctOn": {
1603
+ "keys": ["customer_id"],
1604
+ "resolution": {
1605
+ "strategy": "max",
1606
+ "orderBy": "updated_at"
1607
+ }
1608
+ }
1609
+ }
1610
+ }
1611
+ ```
1612
+
1613
+ ### **Pivot**
1614
+
1615
+ `pivot` groups the data by `rowKeys`, takes the distinct values of `pivotColumn` as new columns, and fills each cell by aggregating `valueColumn` with `aggregation` (`sum`, `count`, `avg`, `min`, `max`). Optionally restrict/prefix the generated columns with `pivotValues` and `columnPrefix`. Cannot be combined with `distinct`/`distinctOn`.
1616
+
1617
+ ```json
1618
+ {
1619
+ "options": {
1620
+ "pivot": {
1621
+ "rowKeys": ["product"],
1622
+ "pivotColumn": "region",
1623
+ "valueColumn": "revenue",
1624
+ "aggregation": "sum",
1625
+ "pivotValues": ["East", "West"],
1626
+ "columnPrefix": "revenue_"
1627
+ }
1628
+ }
1629
+ }
1630
+ ```
1631
+
1632
+ ---
1633
+
1634
+ ## **Dataset Validation**
1635
+
1636
+ Beyond per-field [validation](#field-validation), a consumer can assert conditions on the **whole result set** before it's exported, via the top-level `validate` array. Each entry is a `{ rule, onFail }` pair where `onFail` is `fail` (abort) or `warn` (log and continue).
1637
+
1638
+ **Rules:** `unique_fields` (the given field(s) must be unique across the dataset), `min_rows` / `max_rows` (row-count bounds), `no_duplicates` (no fully-duplicate rows), `not_empty` (at least one row).
1639
+
1640
+ ```json
1641
+ {
1642
+ "validate": [
1643
+ { "rule": { "not_empty": true }, "onFail": "fail" },
1644
+ { "rule": { "unique_fields": ["customer_id"] }, "onFail": "fail" },
1645
+ { "rule": { "min_rows": 1 }, "onFail": "warn" }
1646
+ ]
1647
+ }
1648
+ ```
1649
+
1650
+ ---
1651
+
1652
+ ## **Output Splitting**
1653
+
1654
+ A file output can be broken into several files, by the value of a field, by a size cap, or by both. One `split` block covers all three:
1655
+
1656
+ ```json
1657
+ {
1658
+ "split": {
1659
+ "by": ["country"],
1660
+ "maxFileSizeGb": 2
1661
+ }
1662
+ }
1663
+ ```
1664
+
1665
+ | Property | Meaning |
1666
+ | --- | --- |
1667
+ | `by` | Consumer field key(s) — the **alias** where one is set. Every distinct combination of values is written to its own file, named after the value(s). |
1668
+ | `maxFileSizeGb` | The largest **one** output file may get, in Gb. The file is rotated and a numbered one is started. With `by` set the cap applies per key, not across the whole output. |
1669
+
1670
+ Either property works on its own. The order is fixed: the key decides which rows share a file, the cap then decides how large one of those files may get — rotating across mixed keys would only produce files that need splitting again.
1671
+
1672
+ ### **How the files are named**
1673
+
1674
+ Each file carries a label made of the key value, the rotation index, or both, placed before the extension:
1675
+
1676
+ The extension is the output's own (`.csv`, `.txt`, `.jsonl`, `.parquet`); `.csv` is used here as the example:
1677
+
1678
+ | Configuration | Exported files |
1679
+ | --- | --- |
1680
+ | no split | `sales_0.csv` |
1681
+ | `maxFileSizeGb` only | `sales_0.csv`, `sales_1.csv`, `sales_2.csv` |
1682
+ | `by` only | `sales_Italy.csv`, `sales_Spain.csv` |
1683
+ | both | `sales_Italy_0.csv`, `sales_Italy_1.csv`, `sales_Spain_0.csv` |
1684
+
1685
+ A value that cannot go in a file name (a separator, a space, a dot) becomes an underscore, and a long one is cut short — so two values that would produce the same name are kept apart by a numeric suffix rather than written into one file.
1686
+
1687
+ A row whose key is **null** goes to `_null` and one whose key is **empty** goes to `_empty`. They are separate on purpose: a shared catch-all merges two different things and stays invisible until it matters.
1688
+
1689
+ ### **Limits**
1690
+
1691
+ - **1000 files** per consumer. Splitting on a field with one value per row (a customer id, an order number) fails the run with a named error instead of filling a directory.
1692
+ - An **API** output cannot be split — a response is not a file.
1693
+ - A **hidden** field cannot be a split key when the consumer writes a positional format (`CSV`, `TXT`, `PARQUET`): the file never carries that column.
1694
+ - On a `PARQUET` output, `maxFileSizeGb` caps the **internal staged bytes, not the parquet bytes**. Each file is encoded and compressed after it has been split, so the exported files land comfortably under the cap rather than at it. The cap is there to bound what a destination is asked to take in one object, and rotating a parquet file after it was compressed would mean rewriting it.
1695
+
1696
+ ### **`maximumFileSize` (shorthand)**
1697
+
1698
+ `maximumFileSize` is the older spelling of `split.maxFileSizeGb` and keeps working on its own:
1699
+
1700
+ ```json
1701
+ {
1702
+ "maximumFileSize": 2
1703
+ }
1704
+ ```
1705
+
1706
+ Setting both on one consumer is rejected at compile — move the value into `split` and remove `maximumFileSize`.
1707
+
1708
+ ---
1709
+
1710
+ ## **Metadata and Organization**
1711
+
1712
+ ### **Custom Metadata**
1713
+
1714
+ Add custom tags for organization and filtering:
1715
+
1716
+ ```json
1717
+ {
1718
+ "metadata": {
1719
+ "department": "sales",
1720
+ "priority": "high",
1721
+ "data_classification": "internal",
1722
+ "owner": "analytics-team"
1723
+ }
1724
+ }
1725
+ ```
1726
+
1727
+ ### **Project Organization**
1728
+
1729
+ Group consumers into projects for access control:
1730
+
1731
+ ```json
1732
+ {
1733
+ "project": "customer-analytics"
1734
+ }
1735
+ ```
1736
+
1737
+ ### **Schema Validation**
1738
+
1739
+ Validate output against JSON schemas:
1740
+
1741
+ ```json
1742
+ {
1743
+ "schema": "customer-data-schema"
1744
+ }
1745
+ ```
1746
+
1747
+ ---
1748
+
1749
+ ## **Advanced Examples**
1750
+
1751
+ ### **Healthcare Claims Analysis Consumer**
1752
+
1753
+ ```json
1754
+ {
1755
+ "$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
1756
+ "name": "healthcare-claims-analysis",
1757
+ "description": "Comprehensive healthcare claims analysis with patient demographics",
1758
+ "producers": [
1759
+ { "name": "healthcare-claims" },
1760
+ {
1761
+ "name": "patient-demographics",
1762
+ "joins": [
1763
+ {
1764
+ "otherName": "healthcare-claims",
1765
+ "relationship": "many-to-one",
1766
+ "sql": "${P.member_id} = ${healthcare-claims.member_id}"
1767
+ }
1768
+ ]
1769
+ }
1770
+ ],
1771
+ "fields": [
1772
+ { "key": "claim_id", "alias": "Claim ID" },
1773
+ { "key": "service_date", "alias": "Service Date" },
1774
+ { "key": "diagnosis_code", "alias": "Primary Diagnosis" },
1775
+ { "key": "billed_amount", "alias": "Billed Amount" },
1776
+ { "key": "allowed_amount", "alias": "Allowed Amount" },
1777
+ {
1778
+ "key": "patient_age_group",
1779
+ "from": "patient-demographics",
1780
+ "alias": "Age Group"
1781
+ },
1782
+ {
1783
+ "key": "billed_vs_allowed",
1784
+ "transform": {
1785
+ "combine_fields": {
1786
+ "fields": ["billed_amount", "allowed_amount"],
1787
+ "template": "{billed_amount} billed / {allowed_amount} allowed"
1788
+ }
1789
+ }
1790
+ },
1791
+ {
1792
+ "key": "high_value_claim",
1793
+ "transform": {
1794
+ "conditional": {
1795
+ "clauses": [
1796
+ { "if": { "greater_than": 10000 }, "then": true }
1797
+ ],
1798
+ "else": false}
1799
+ }
1800
+ }
1801
+ ],
1802
+ "filters": [
1803
+ {
1804
+ "rule": {
1805
+ "member": "service_date",
1806
+ "operator": "between",
1807
+ "values": ["2024-01-01", "2024-12-31"]
1808
+ }
1809
+ },
1810
+ {
1811
+ "rule": {
1812
+ "member": "claim_status",
1813
+ "operator": "equals",
1814
+ "values": ["Processed"]
1815
+ }
1816
+ }
1817
+ ],
1818
+ "outputs": [
1819
+ {
1820
+ "format": "JSON",
1821
+ "exportDestination": "analytics-bucket",
1822
+ "exportName": "claims-analysis-2024"
1823
+ },
1824
+ {
1825
+ "format": "CSV",
1826
+ "exportDestination": "reports-folder",
1827
+ "exportName": "claims-summary"
1828
+ }
1829
+ ],
1830
+ "options": {
1831
+ "distinct": true},
1832
+ "metadata": {
1833
+ "department": "healthcare-analytics",
1834
+ "data_classification": "PHI",
1835
+ "retention_period": "7_years"
1836
+ },
1837
+ "project": "healthcare-analytics",
1838
+ "_version": 1
1839
+ }
1840
+ ```
1841
+
1842
+ ### **Sales Performance Dashboard Consumer**
1843
+
1844
+ ```json
1845
+ {
1846
+ "$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
1847
+ "name": "sales-performance-dashboard",
1848
+ "description": "Real-time sales performance metrics for executive dashboard",
1849
+ "producers": [
1850
+ { "name": "sales-transactions" },
1851
+ {
1852
+ "name": "product-catalog",
1853
+ "joins": [
1854
+ {
1855
+ "otherName": "sales-transactions",
1856
+ "relationship": "many-to-one",
1857
+ "sql": "${P.product_id} = ${sales-transactions.product_id}"
1858
+ }
1859
+ ]
1860
+ },
1861
+ {
1862
+ "name": "sales-reps",
1863
+ "joins": [
1864
+ {
1865
+ "otherName": "sales-transactions",
1866
+ "relationship": "many-to-one",
1867
+ "sql": "${P.rep_id} = ${sales-transactions.sales_rep_id}"
1868
+ }
1869
+ ]
1870
+ }
1871
+ ],
1872
+ "fields": [
1873
+ { "key": "transaction_id", "alias": "Transaction ID" },
1874
+ { "key": "sale_date", "alias": "Sale Date" },
1875
+ {
1876
+ "key": "sale_month",
1877
+ "transform": { "extract": "month" },
1878
+ "alias": "Month"
1879
+ },
1880
+ {
1881
+ "key": "sale_year",
1882
+ "transform": { "extract": "year" },
1883
+ "alias": "Year"
1884
+ },
1885
+ { "key": "amount", "alias": "Sale Amount" },
1886
+ {
1887
+ "key": "commission",
1888
+ "transform": { "multiply": 0.05 },
1889
+ "alias": "Commission (5%)"
1890
+ },
1891
+ { "key": "product_name", "from": "product-catalog", "alias": "Product" },
1892
+ { "key": "category", "from": "product-catalog", "alias": "Category" },
1893
+ { "key": "rep_name", "from": "sales-reps", "alias": "Sales Rep" },
1894
+ { "key": "region", "from": "sales-reps", "alias": "Region" },
1895
+ {
1896
+ "key": "performance_tier",
1897
+ "transform": {
1898
+ "conditional": {
1899
+ "clauses": [
1900
+ { "if": { "greater_than_or_equal": 50000 }, "then": "Platinum" },
1901
+ { "if": { "greater_than_or_equal": 25000 }, "then": "Gold" },
1902
+ { "if": { "greater_than_or_equal": 10000 }, "then": "Silver" }
1903
+ ],
1904
+ "else": "Bronze"
1905
+ }
1906
+ }
1907
+ }
1908
+ ],
1909
+ "filters": [
1910
+ {
1911
+ "rule": {
1912
+ "member": "sale_date",
1913
+ "operator": "greaterThanOrEquals",
1914
+ "values": ["2024-01-01"]
1915
+ }
1916
+ },
1917
+ {
1918
+ "rule": {
1919
+ "member": "status",
1920
+ "operator": "equals",
1921
+ "values": ["Completed"]
1922
+ }
1923
+ }
1924
+ ],
1925
+ "outputs": [
1926
+ {
1927
+ "format": "API"
1928
+ },
1929
+ {
1930
+ "format": "JSON",
1931
+ "exportDestination": "dashboard-cache",
1932
+ "trigger": {
1933
+ "type": "CRON",
1934
+ "value": "*/15 * * * *",
1935
+ "metadata": {
1936
+ "description": "Update every 15 minutes"
1937
+ }
1938
+ }
1939
+ }
1940
+ ],
1941
+ "metadata": {
1942
+ "dashboard": "executive-sales",
1943
+ "refresh_rate": "15_minutes",
1944
+ "data_classification": "internal"
1945
+ },
1946
+ "project": "sales-analytics",
1947
+ "_version": 1
1948
+ }
1949
+
1950
+ ```
1951
+
1952
+ ### **Data Union Consumer**
1953
+
1954
+ ```json
1955
+ {
1956
+ "$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/consumer-schema.json",
1957
+ "name": "historical-sales-union",
1958
+ "description": "Combined historical sales data from multiple years",
1959
+ "producers": [
1960
+ { "name": "sales-2022", "union": true },
1961
+ { "name": "sales-2023", "union": true },
1962
+ { "name": "sales-2024", "union": true }
1963
+ ],
1964
+ "fields": [
1965
+ { "key": "transaction_id", "alias": "ID" },
1966
+ { "key": "customer_id", "alias": "Customer" },
1967
+ { "key": "product_id", "alias": "Product" },
1968
+ { "key": "sale_date", "alias": "Date" },
1969
+ { "key": "amount", "alias": "Amount" },
1970
+ {
1971
+ "key": "data_source",
1972
+ "transform": { "extract": "year" },
1973
+ "alias": "Source Year"
1974
+ }
1975
+ ],
1976
+ "filters": [
1977
+ {
1978
+ "rule": {
1979
+ "member": "amount",
1980
+ "operator": "greaterThan",
1981
+ "values": ["0"]
1982
+ }
1983
+ }
1984
+ ],
1985
+ "outputs": [
1986
+ {
1987
+ "format": "CSV",
1988
+ "exportDestination": "data-warehouse",
1989
+ "exportName": "historical-sales-combined"
1990
+ }
1991
+ ],
1992
+ "options": {
1993
+ "distinct": true},
1994
+ "_version": 1
1995
+ }
1996
+ ```
1997
+
1998
+ ---
1999
+
2000
+ ## **Validation and Testing**
2001
+
2002
+ ### **Configuration Validation**
2003
+
2004
+ ```bash
2005
+ # Validate (compile) all resource configurations in the project
2006
+ remora compile
2007
+ ```
2008
+
2009
+ ### **Consumer Testing**
2010
+
2011
+ ```bash
2012
+ # Run a single consumer
2013
+ remora run consumer-name
2014
+
2015
+ # Run with a limited number of output records (useful for a quick test)
2016
+ remora run consumer-name --limit 10
2017
+
2018
+ # Run every consumer belonging to a project
2019
+ remora run --project project-name
2020
+ ```
2021
+
2022
+ #### Sampling a consumer
2023
+
2024
+ ```bash
2025
+ # See what this consumer produces, without writing anything anywhere
2026
+ remora sample consumer-name
2027
+
2028
+ # Show a specific number of rows
2029
+ remora sample consumer-name -s 20
2030
+ ```
2031
+
2032
+ `remora sample` on a consumer **runs its pipeline for real** — filters, field transformations,
2033
+ masking, unions, `distinct`, `pivot` and dataset validation all apply, exactly as in a
2034
+ run. So the rows it shows are the rows the consumer produces, always: it is a real run's output, not
2035
+ an approximation of it.
2036
+
2037
+ What it does *not* do is touch anything. A sample writes **nothing to the destination**, fires **no**
2038
+ `onSuccess`/`onError` action, and — although it is recorded like any execution — is marked as a
2039
+ sample, so it stays out of your run history, out of a consumer's run list and out of its duration
2040
+ averages.
2041
+
2042
+ The number of rows is bounded by the same limit `remora run --limit` uses, which means the cost
2043
+ depends on what the consumer does. Where nothing between the source and the output can *drop* a
2044
+ record, the limit reaches the source itself and the read stops early, so the sample is quick whatever
2045
+ the source's size. Where something can — any filter, `distinct`, `distinctOn`, `pivot`, or a field
2046
+ validation set to `"onFail": "skip"` — there is no way to know how many rows have to go in for ten to
2047
+ come out, so **the whole source is read**. On a large source that is slow. It is still only a read:
2048
+ nothing is written and nothing is recorded against the consumer.
2049
+
2050
+ Sampling a consumer needs a source the pipeline can read — `local`, `aws-s3` or `delta-share`. A
2051
+ consumer whose producer reads `aws-redshift` or `http-api` is refused with the reason, naming the
2052
+ producer; sample that **producer** directly to see its rows.
2053
+
2054
+ ### **Common Validation Errors**
2055
+
2056
+ | Error | Cause | Solution |
2057
+ | --- | --- | --- |
2058
+ | `Producer not found` | Invalid producer reference | Check producer name exists |
2059
+ | `Join relationship invalid` | Incorrect join SQL or relationship type | Verify join syntax and relationship |
2060
+ | `Field not found` | Field doesn't exist in producer | Check field names and aliases |
2061
+ | `Ambiguous field reference` | Field exists in multiple producers | Use `from` property to specify producer |
2062
+ | `Invalid transformation` | Unsupported transformation or wrong parameters | Check transformation syntax |
2063
+ | `Filter validation failed` | Filter references non-existent field | Ensure filter fields exist in consumer |
2064
+
2065
+ ---
2066
+
2067
+ ## **Performance Optimization**
2068
+
2069
+ ### **SQL-Based Optimization**
2070
+
2071
+ 1. **Use appropriate join relationships** to optimize query execution
2072
+ 2. **Filter early** with WHERE clauses to reduce data processing
2073
+ 3. **Limit field selection** to reduce memory usage
2074
+
2075
+ ### **File-Based Optimization**
2076
+
2077
+ 1. **Use rule-based filters** instead of processing all data
2078
+ 2. **Select only needed fields** to reduce memory footprint
2079
+ 3. **Use streaming** for large file processing
2080
+ 4. **Optimize transformations** to avoid expensive operations
2081
+
2082
+ ### **Memory Management**
2083
+
2084
+ Configure memory settings in project configuration:
2085
+
2086
+ ```json
2087
+ {
2088
+ "settings": {
2089
+ "MAX_ITEMS_IN_MEMORY": 100000,
2090
+ "INDICATIVE_THREAD_LINE_COUNT": 10000,
2091
+ "MAX_THREAD_COUNT": 4
2092
+ }
2093
+ }
2094
+ ```
2095
+
2096
+ ---
2097
+
2098
+ ## **Best Practices**
2099
+
2100
+ ### **Naming Conventions**
2101
+
2102
+ - **Consumer names**: Use descriptive, lowercase with hyphens (`healthcare-claims-analysis`)
2103
+ - **Field aliases**: Use descriptive, human-readable names (`"Customer ID"`, `"Total Sales"`)
2104
+ - **Export names**: Use consistent naming for file exports (`daily-report`, `customer-analysis-2024`)
2105
+
2106
+ ### **Performance**
2107
+
2108
+ 1. **Filter early and often** to reduce data processing
2109
+ 2. **Use appropriate join types** for your data relationships
2110
+ 3. **Limit field selection** to only what's needed
2111
+ 4. **Monitor execution times** and optimize bottlenecks
2112
+
2113
+ ### **Security**
2114
+
2115
+ 1. **Apply filters** to limit data exposure
2116
+ 2. **Use project organization** for access control
2117
+ 3. **Document data classifications** in metadata
2118
+ 4. **Validate schema compliance** where required
2119
+
2120
+ ### **Maintainability**
2121
+
2122
+ 1. **Add meaningful descriptions** to all consumers
2123
+ 2. **Use consistent field aliasing** across related consumers
2124
+ 3. **Document business logic** in transformation comments
2125
+ 4. **Version control** consumer configurations
2126
+ 5. **Test thoroughly** before production deployment
2127
+
2128
+ ---
2129
+
2130
+ ## **Troubleshooting**
2131
+
2132
+ ### **Common Issues**
2133
+
2134
+ **Join Failures:**
2135
+
2136
+ ```
2137
+ Error: Unable to join producers - relationship constraint violated
2138
+ ```
2139
+
2140
+ - Verify join relationships match data cardinality
2141
+ - Check join SQL syntax and field references
2142
+ - Ensure joined fields have compatible data types
2143
+
2144
+ **Transformation Errors:**
2145
+
2146
+ ```
2147
+ Error: Cast transformation failed for field 'amount'
2148
+ ```
2149
+
2150
+ - Check source data format and target type compatibility
2151
+ - Add error handling with `onError: "set_default"`
2152
+ - Verify transformation parameters
2153
+
2154
+ **Filter Errors:**
2155
+
2156
+ ```
2157
+ Error: Filter field 'status' not found in consumer
2158
+ ```
2159
+
2160
+ - Ensure filtered fields exist in field selection
2161
+ - Check field aliases in filter references
2162
+ - Verify producer field names
2163
+
2164
+ **Memory Issues:**
2165
+
2166
+ ```
2167
+ Error: Out of memory processing large dataset
2168
+ ```
2169
+
2170
+ - Increase `MAX_ITEMS_IN_MEMORY` setting
2171
+ - Add filters to reduce data size
2172
+ - Use streaming for large files
2173
+ - Optimize field selection
2174
+
2175
+ ### **Debug Mode**
2176
+
2177
+ Enable detailed logging:
2178
+
2179
+ ```json
2180
+ {
2181
+ "settings": {
2182
+ "LOG_LEVEL": "DEBUG",
2183
+ "DEBUG_MODE": true
2184
+ }
2185
+ }
2186
+ ```
2187
+
2188
+ ### **Support Resources**
2189
+
2190
+ - **Schema validation**: Automatic validation against JSON schemas
2191
+ - **CLI help**: Run `remora --help` for command documentation
2192
+ - **Error messages**: Include specific guidance for resolution
2193
+ - **Execution stats**: Review processing statistics for optimization
2194
+
2195
+ ---
2196
+
2197
+ ## **Migration and Versioning**
2198
+
2199
+ ### **Consumer Versioning**
2200
+
2201
+ Update the `_version` field when making significant changes:
2202
+
2203
+ ```json
2204
+ {
2205
+ "name": "customer-analysis",
2206
+ "_version": 2
2207
+ }
2208
+ ```
2209
+
2210
+ ### **Schema Evolution**
2211
+
2212
+ When evolving consumer configurations:
2213
+
2214
+ 1. **Test changes** in development environment first
2215
+ 2. **Update field references** when producer schemas change
2216
+ 3. **Maintain backwards compatibility** where possible
2217
+ 4. **Document breaking changes** in version notes
2218
+
2219
+ ### **Deployment Strategies**
2220
+
2221
+ 1. **Blue-green deployment** for production consumers
2222
+ 2. **Canary releases** for high-volume consumers
2223
+ 3. **Rollback procedures** for failed deployments
2224
+ 4. **Health checks** for automated consumers