@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.
- package/CHANGELOG.md +47 -0
- package/docs/Remora - Code Sets.md +268 -0
- package/docs/Remora - Consumers.md +2224 -0
- package/docs/Remora - Producers.md +1658 -0
- package/docs/Remora - Sources.md +1150 -0
- package/documentation/README.md +262 -130
- package/documentation/default_resources/mock_data.csv +2 -2
- package/index.js +2151 -1597
- package/json_schemas/consumer-schema.json +146 -1
- package/package.json +1 -1
- package/workers/ExecutorWorker.js +572 -47
|
@@ -0,0 +1,1658 @@
|
|
|
1
|
+
# Remora - Producers
|
|
2
|
+
|
|
3
|
+
## **Overview**
|
|
4
|
+
|
|
5
|
+
Producers in Remora define how data is extracted from sources and specify the structure and characteristics of that data. They act as the data extraction layer between sources and consumers, providing schema definition, data transformation, and security controls.
|
|
6
|
+
|
|
7
|
+
Each producer configuration specifies:
|
|
8
|
+
|
|
9
|
+
- **Data source connection** (which source to use)
|
|
10
|
+
- **Data schema** (dimensions)
|
|
11
|
+
- **Extraction settings** (file type, compression type, SQL tables, processing options and more)
|
|
12
|
+
- **Security controls** (data classification and masking)
|
|
13
|
+
|
|
14
|
+
## **Core Components**
|
|
15
|
+
|
|
16
|
+
### **Producer Structure**
|
|
17
|
+
|
|
18
|
+
All producers follow this basic structure:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
23
|
+
"name": "producer-name",
|
|
24
|
+
"description": "Producer description",
|
|
25
|
+
"source": "source-name",
|
|
26
|
+
"settings": {/* extraction settings */ },
|
|
27
|
+
"dimensions": [/* field definitions */ ],
|
|
28
|
+
"_version": 1
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### **Producer Types by Source Engine**
|
|
33
|
+
|
|
34
|
+
| Source Engine | Data Type | Use Cases |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `local` | Files (JSON, CSV, Excel, XML, etc.) | Development, testing, file-based processing |
|
|
37
|
+
| `aws-s3` | Cloud storage files | Data lakes, archives, distributed file processing |
|
|
38
|
+
| `aws-redshift` | SQL tables/views | Data warehousing, analytics, structured data |
|
|
39
|
+
| `delta-share` | Shared datasets | Cross-organization data sharing |
|
|
40
|
+
| `http-api` | REST API responses | External services, SaaS integrations |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## **File-Based Producers**
|
|
45
|
+
|
|
46
|
+
File-based producers work with local files or S3 storage, supporting various file formats.
|
|
47
|
+
|
|
48
|
+
### **Common Configuration Schema**
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
53
|
+
"name": "file-producer",
|
|
54
|
+
"description": "File-based data producer",
|
|
55
|
+
"source": "local",
|
|
56
|
+
"settings": {
|
|
57
|
+
"fileKey": "data-file.csv",
|
|
58
|
+
"fileType": "CSV",
|
|
59
|
+
"delimiter": ",",
|
|
60
|
+
"hasHeaderRow": true},
|
|
61
|
+
"dimensions": [/* field definitions */ ],
|
|
62
|
+
"_version": 1
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### **Settings Properties**
|
|
67
|
+
|
|
68
|
+
| Property | Required | Description | Example |
|
|
69
|
+
| --- | --- | --- | --- |
|
|
70
|
+
| `fileKey` | ✅ | File name or path pattern | `"data.csv"`, `"files/data%.json"` |
|
|
71
|
+
| `fileType` | ✅ | File format type | `"CSV"`, `"JSON"`, `"JSONL"`, `"XLSX"`, `"XLS"`, `"XML"`, `"TXT"`, `"PARQUET"`, `"FIXED"` |
|
|
72
|
+
| `delimiter` | ❌ | CSV delimiter character | `","`, `";"`, `"\t"` |
|
|
73
|
+
| `hasHeaderRow` | ❌ | Whether CSV/TXT has header row. For `FIXED` it defaults to `false` — set it to `true` only if the file starts with a line to skip | `true`, `false` |
|
|
74
|
+
| `recordSelector` | ❌ | `FIXED`: read only the lines whose marker at a given position matches, optionally asserting a `minLength` | `{ "position": { "start": 1, "length": 1 }, "equals": "D", "minLength": 2400 }` |
|
|
75
|
+
| `sheetName` | ❌ | Excel sheet name to read | `"Sheet1"`, `"Data"` |
|
|
76
|
+
| `compressionType` | ❌ | Decompress the source file before parsing | `"GZ"`, `"ZIP"`, `"TAR"` |
|
|
77
|
+
| `startRow` | ❌ | Excel: 1-indexed row to start reading from | `1`, `3` |
|
|
78
|
+
| `startColumn` | ❌ | Excel: 1-indexed column to start reading from | `1`, `2` |
|
|
79
|
+
|
|
80
|
+
### **Supported File Types**
|
|
81
|
+
|
|
82
|
+
### **CSV Files**
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"settings": {
|
|
87
|
+
"fileKey": "customers.csv",
|
|
88
|
+
"fileType": "CSV",
|
|
89
|
+
"delimiter": ",",
|
|
90
|
+
"hasHeaderRow": true}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### **JSON Files**
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"settings": {
|
|
99
|
+
"fileKey": "transactions.json",
|
|
100
|
+
"fileType": "JSON"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### **JSON Lines (JSONL/NDJSON)**
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"settings": {
|
|
110
|
+
"fileKey": "events.jsonl",
|
|
111
|
+
"fileType": "JSONL"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### **Excel Files**
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"settings": {
|
|
121
|
+
"fileKey": "report.xlsx",
|
|
122
|
+
"fileType": "XLSX",
|
|
123
|
+
"sheetName": "Data"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### **XML Files**
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"settings": {
|
|
133
|
+
"fileKey": "config.xml",
|
|
134
|
+
"fileType": "XML"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### **Text Files**
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"settings": {
|
|
144
|
+
"fileKey": "log.txt",
|
|
145
|
+
"fileType": "TXT",
|
|
146
|
+
"delimiter": "\t",
|
|
147
|
+
"hasHeaderRow": false}
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### **Fixed-Width Files (FIXED)**
|
|
152
|
+
|
|
153
|
+
A fixed-width (positional) flat file has no delimiter and no header: every field is identified by where it sits in the line. Each dimension declares a `position`, and that position *is* the mapping — `alias` and `delimiter` are unused.
|
|
154
|
+
|
|
155
|
+
Coordinates are **1-based and inclusive**, exactly how a file layout spec is written ("columns 21-30"). Give the width as either `length` or `end` (never both):
|
|
156
|
+
|
|
157
|
+
| Property | Required | Description | Example |
|
|
158
|
+
| --- | --- | --- | --- |
|
|
159
|
+
| `position.start` | ✅ | 1-based position of the field's first character | `21` |
|
|
160
|
+
| `position.length` | ⚠️ | How many characters the field spans (or use `end`) | `10` |
|
|
161
|
+
| `position.end` | ⚠️ | 1-based position of the field's last character, inclusive (or use `length`) | `30` |
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"settings": {
|
|
166
|
+
"fileKey": "claims.txt",
|
|
167
|
+
"fileType": "FIXED"
|
|
168
|
+
},
|
|
169
|
+
"dimensions": [
|
|
170
|
+
{ "name": "member_id", "type": "string", "position": { "start": 1, "length": 12 } },
|
|
171
|
+
{ "name": "service_date", "type": "datetime", "format": "YYYYMMDD", "position": { "start": 13, "length": 8 } },
|
|
172
|
+
{ "name": "amount", "type": "number", "position": { "start": 21, "end": 30 } }
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Notes:
|
|
178
|
+
|
|
179
|
+
- Values are **trimmed**, so space- and zero-padded fields (`"0000123.45"`, `"ABC "`) read as their real value. A field that is entirely padding reads as `null` — or as its type's empty value, if the dimension declares `nullable: false`.
|
|
180
|
+
- Gaps are fine: filler columns you don't map simply have no dimension. Overlaps are fine too — a composite field and its subfields can both be declared.
|
|
181
|
+
- Combine `position` with `format` to parse dates written as bare digits, as in `service_date` above. Day-of-year ("Julian") dates use `DDD`: `"format": "YYYYDDD"` reads `2007305` as 1 November 2007.
|
|
182
|
+
- A dimension marked `sourceFilename` needs no `position`: it isn't in the line.
|
|
183
|
+
- `discover` cannot be used on a `FIXED` producer — a positional layout can only come from the file's spec. Use `sample` to check that your positions line up.
|
|
184
|
+
|
|
185
|
+
### **Repeating Groups (OCCURS)**
|
|
186
|
+
|
|
187
|
+
Flat files transcribed from a COBOL copybook repeat a segment of fields a fixed number of times — three address slots, 36 reimbursement rows. Declaring each occurrence by hand means writing the same fields once per slot, with every offset shifted. A **group dimension** declares them once instead: a dimension that carries its own nested `dimensions` and no `type` of its own.
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"name": "addr",
|
|
192
|
+
"description": "Address segment, 3 occurrences",
|
|
193
|
+
"position": { "start": 32, "length": 30 },
|
|
194
|
+
"occurs": 3,
|
|
195
|
+
"dimensions": [
|
|
196
|
+
{ "name": "street", "type": "string", "position": { "start": 1, "length": 20 } },
|
|
197
|
+
{ "name": "state", "type": "string", "position": { "start": 21, "length": 2 } },
|
|
198
|
+
{ "name": "zip", "type": "string", "position": { "start": 23, "length": 5 } }
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Two rules carry the whole feature:
|
|
204
|
+
|
|
205
|
+
- **The group's `position` is the extent of ONE occurrence** — `start` is where the *first* occurrence begins, and `length` is the bytes *per occurrence*, not the group's whole span. (The `end` form means the same thing: `{ "start": 32, "end": 61 }` is occurrence 1 only.)
|
|
206
|
+
- **A child's `position` is relative to its occurrence**, so it reads exactly as the copybook writes it: `street` is characters 1-20 *of an address*, not of the line.
|
|
207
|
+
|
|
208
|
+
The group expands into one dimension per (occurrence × child) when the project loads — `addr0Street`, `addr0State`, `addr0Zip`, `addr1Street`, … `addr2Zip` above, at 32-51, 52-53, 54-58, then 62-81, … 114-118. Everything downstream sees plain dimensions, so a group changes nothing about how consumers, transforms, validations or exports work: it is purely a way to write the layout once.
|
|
209
|
+
|
|
210
|
+
| Property | Required | Description | Example |
|
|
211
|
+
| --- | --- | --- | --- |
|
|
212
|
+
| `dimensions` | ✅ | The fields that repeat, each `position` relative to one occurrence | see above |
|
|
213
|
+
| `position` | ✅ | Extent of **one** occurrence: `start` of the first, `length` per occurrence | `{ "start": 32, "length": 30 }` |
|
|
214
|
+
| `occurs` | ❌ | Number of occurrences. Default `1`, max `1000`. An object when the count varies per line — see [Variable Occurrences](#variable-occurrences-and-anchoring) | `3` |
|
|
215
|
+
| `indexFrom` | ❌ | First index used in generated names: `0` (default) or `1` | `1` |
|
|
216
|
+
| `naming` | ❌ | Name template. Tokens `{group}`, `{index}`, `{field}`, `{Field}` (capitalized). `{index}` is required unless `occurs` is `1`. Default `{group}{index}{Field}` | `{group}_{index}_{field}` → `addr_0_street` |
|
|
217
|
+
|
|
218
|
+
Notes:
|
|
219
|
+
|
|
220
|
+
- A group **expands in place**, at its own index, so the columns follow the physical order of the record. A dimension declared after the group stays after it.
|
|
221
|
+
- A group carries no `type`, `alias`, `pk`, `mask`, `synth`, `format` or `sourceFilename` — it produces no column of its own, so those belong on its children.
|
|
222
|
+
- `occurs: 1` is useful on its own: a group is then simply a way to **structure** a segment of the layout, and with `"naming": "{field}"` the children keep their own names.
|
|
223
|
+
- Unpopulated occurrences read as `null`, exactly like any all-padding field.
|
|
224
|
+
- A child position that doesn't fit inside one occurrence is rejected — that is the classic transcription slip, an absolute offset pasted where a relative one belongs.
|
|
225
|
+
- Nested groups (a group inside a group) are **not supported yet** and are rejected with a message saying so. Declare the inner group as a sibling with its own absolute `position`.
|
|
226
|
+
|
|
227
|
+
### **Occurrences as Rows**
|
|
228
|
+
|
|
229
|
+
By default a group's occurrences become **columns** — `addr0Street`, `addr1Street`, `addr2Street`. `"emit": "rows"` turns them into **records** instead: one output record per occurrence, which is usually what you want from a slot list that is mostly empty.
|
|
230
|
+
|
|
231
|
+
```json
|
|
232
|
+
{
|
|
233
|
+
"name": "addr",
|
|
234
|
+
"position": { "start": 39, "length": 30 },
|
|
235
|
+
"occurs": 3,
|
|
236
|
+
"emit": "rows",
|
|
237
|
+
"occurrenceField": "addr_occurrence",
|
|
238
|
+
"dimensions": [
|
|
239
|
+
{ "name": "street", "type": "string", "position": { "start": 1, "length": 20 } },
|
|
240
|
+
{ "name": "state", "type": "string", "position": { "start": 21, "length": 2 } }
|
|
241
|
+
]
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
One line with two populated addresses comes out as two records:
|
|
246
|
+
|
|
247
|
+
```json
|
|
248
|
+
{"id":"USR-1","addr_occurrence":0,"street":"12 MAIN ST","state":"OR","plan":"free"}
|
|
249
|
+
{"id":"USR-1","addr_occurrence":1,"street":"7 ELM AVE","state":"WA","plan":"free"}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The children keep their **own names** (there is no index to tell them apart, so `naming` defaults to `{field}` and a `{index}` token is rejected), every dimension outside the group **repeats** on each record, and a generated field carries the occurrence index.
|
|
253
|
+
|
|
254
|
+
| Property | Required | Description | Example |
|
|
255
|
+
| --- | --- | --- | --- |
|
|
256
|
+
| `emit` | ❌ | `columns` (default) or `rows` | `"rows"` |
|
|
257
|
+
| `occurrenceField` | ❌ | Name of the generated occurrence-index field. Default `{group}Occurrence` | `"addr_occurrence"` |
|
|
258
|
+
| `skipEmptyOccurrences` | ❌ | Drop occurrences whose fields are all empty. Default `true` | `false` |
|
|
259
|
+
|
|
260
|
+
Notes:
|
|
261
|
+
|
|
262
|
+
- **At most one group per producer** may fan out — two would multiply their occurrences together.
|
|
263
|
+
- Records stay flat, so CSV/TXT export, `pivot`, `distinctOn`, transforms and validations all keep working exactly as they do on any other producer.
|
|
264
|
+
- Empty occurrences are dropped by default: a layout reserving 121 slots usually populates a handful, and the empty records would bury the real ones. Set `skipEmptyOccurrences: false` to keep them.
|
|
265
|
+
- A `lineCount` field still means the **source line**, so the records fanned out of one line share it. Dataset row counts (`min_rows`/`max_rows`) count output records, which is what fanning out changes.
|
|
266
|
+
- `indexFrom` numbers the occurrences here too, as the value of the occurrence field rather than part of a name.
|
|
267
|
+
|
|
268
|
+
### **Variable Occurrences and Anchoring**
|
|
269
|
+
|
|
270
|
+
Some layouts repeat a segment a number of times that **changes from line to line** — 34 reimbursement rows on one record, 36 on the next. Every field after such a block sits at a different column on almost every line, so an absolute `position` there doesn't fail: it reads plausible, wrong values. Two properties close that gap.
|
|
271
|
+
|
|
272
|
+
**`occurs` as an object** declares a count the line decides:
|
|
273
|
+
|
|
274
|
+
```json
|
|
275
|
+
{
|
|
276
|
+
"name": "reimb",
|
|
277
|
+
"position": { "start": 500, "length": 24 },
|
|
278
|
+
"occurs": { "min": 34, "max": 36, "while": { "position": { "start": 1, "length": 1 }, "equals": "R" } },
|
|
279
|
+
"dimensions": [ ... ]
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
```json
|
|
284
|
+
{
|
|
285
|
+
"name": "progId",
|
|
286
|
+
"position": { "after": "reimb", "length": 18 },
|
|
287
|
+
"occurs": { "from": "$slotCount", "max": 61 },
|
|
288
|
+
"dimensions": [ ... ]
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
| Property | Required | Description | Example |
|
|
293
|
+
| --- | --- | --- | --- |
|
|
294
|
+
| `occurs.max` | ✅ | Upper bound, and **how many columns the group generates** | `36` |
|
|
295
|
+
| `occurs.min` | ❌ | Occurrences every line has, whatever the rule reports. Default `0` | `34` |
|
|
296
|
+
| `occurs.while` | ⚠️ | Take occurrences while this marker matches. Its `position` is relative to one occurrence, like a child's | `{ "position": { "start": 1, "length": 1 }, "equals": "R" }` |
|
|
297
|
+
| `occurs.from` | ⚠️ | Read the count from an earlier dimension, referenced with `$`. Floored at `min`, capped at `max` | `"$slotCount"` |
|
|
298
|
+
|
|
299
|
+
Exactly one of `while` / `from` is required, and `max` is mandatory in both forms: the output columns have to be knowable before a single line is read.
|
|
300
|
+
|
|
301
|
+
**`position.after`** anchors a position to the end of a group's *actual* occurrences on that line, instead of to the start of the line:
|
|
302
|
+
|
|
303
|
+
```json
|
|
304
|
+
{ "name": "plan_code", "type": "string", "position": { "after": "progId", "length": 10 } }
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`after` replaces `start`. To sit further into the anchored region, keep `start` as an offset inside it: `{ "after": "progId", "start": 11, "length": 2 }` reads characters 11-12 after the block. It must name a **group declared earlier** in the layout, and both groups and plain dimensions can use it.
|
|
308
|
+
|
|
309
|
+
Notes:
|
|
310
|
+
|
|
311
|
+
- **The column set stays fixed at `max`.** A line carrying fewer occurrences reads the missing ones as `null` — never as the characters of whatever follows the block.
|
|
312
|
+
- Only producers that need it pay for it: positions are still resolved once, and only a layout with a variable group or an `after` anchor is resolved per line.
|
|
313
|
+
- A dimension declared after a variable group but pinned to an absolute column is **warned** about at compile, not rejected — a layout is free to leave a variable block last, or to pad records to a fixed length.
|
|
314
|
+
- `occurs.from` must reference a single field at an absolute position, declared before the group: the count has to be readable before the occurrences it counts.
|
|
315
|
+
|
|
316
|
+
### **Flag Bitmaps**
|
|
317
|
+
|
|
318
|
+
Flat files often pack a set of yes/no indicators into one string, one character each (`"01101000"`). `flags` decodes such a field into one dimension per character, named after each flag:
|
|
319
|
+
|
|
320
|
+
```json
|
|
321
|
+
{
|
|
322
|
+
"name": "addressUse",
|
|
323
|
+
"type": "string",
|
|
324
|
+
"position": { "start": 40, "length": 8 },
|
|
325
|
+
"flags": ["mailing", "home", "billing", "emergency", "work", "school", "temp", "other"]
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
That yields `addressUseMailing` … `addressUseOther`, each a 1-character slice starting at `position.start`. `flags` must list exactly one name per character the dimension spans.
|
|
330
|
+
|
|
331
|
+
Each character is **sliced, never interpreted**, so a value that isn't strictly binary survives as itself (`"3"` stays `"3"`) — decode the characters into real values downstream with a consumer `conditional` or `code_lookup` transform if you need to.
|
|
332
|
+
|
|
333
|
+
Use `naming` to shape the generated names, with the tokens `{field}`, `{Field}`, `{flag}` and `{Flag}` — e.g. `"naming": "{field}_{flag}"` gives `addressUse_mailing`. `flags` works on a `string` dimension, including one inside a repeating group, where the group's naming applies first.
|
|
334
|
+
|
|
335
|
+
### **Mixed Record Types (H/D/T)**
|
|
336
|
+
|
|
337
|
+
Flat files often interleave record types in one file — a header, many detail records and a trailer — that share no layout. `recordSelector` reads only the lines whose marker matches, so one producer describes exactly one record layout:
|
|
338
|
+
|
|
339
|
+
```json
|
|
340
|
+
{
|
|
341
|
+
"settings": {
|
|
342
|
+
"fileKey": "claims.txt",
|
|
343
|
+
"fileType": "FIXED",
|
|
344
|
+
"recordSelector": {
|
|
345
|
+
"position": { "start": 1, "length": 1 },
|
|
346
|
+
"equals": "D"
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Every other line (the `H` header and `T` trailer above) is dropped before parsing. `equals` also takes a list (`["D", "E"]`) when several record types share the same layout. To read the header or trailer records too, add a second producer over the same file with its own selector and positions, and combine them in a consumer.
|
|
353
|
+
|
|
354
|
+
A matching marker only says the line *claims* to be that record type, not that all of it is there. `minLength` asserts the rest of it is:
|
|
355
|
+
|
|
356
|
+
```json
|
|
357
|
+
{
|
|
358
|
+
"settings": {
|
|
359
|
+
"recordSelector": {
|
|
360
|
+
"position": { "start": 1, "length": 1 },
|
|
361
|
+
"equals": "D",
|
|
362
|
+
"minLength": 2400,
|
|
363
|
+
"onLengthMismatch": "skip"
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
A matching line shorter than `minLength` is dropped and logged at debug level (`"skip"`, the default), or fails the run (`"onLengthMismatch": "fail"`). Without it a truncated record parses into a plausible half-record whose missing tail reads as `null`. The check applies only to lines the marker already matched, so a short header or trailer is unaffected.
|
|
370
|
+
|
|
371
|
+
| Property | Required | Description | Example |
|
|
372
|
+
| --- | --- | --- | --- |
|
|
373
|
+
| `position` | ✅ | Where the record-type marker sits in the line | `{ "start": 1, "length": 1 }` |
|
|
374
|
+
| `equals` | ✅ | Marker value(s) to keep, compared against the trimmed marker | `"D"`, `["D", "E"]` |
|
|
375
|
+
| `minLength` | ❌ | Shortest a valid line of this record type can be, in characters | `2400` |
|
|
376
|
+
| `onLengthMismatch` | ❌ | What to do with a matching line that is too short | `"skip"` (default), `"fail"` |
|
|
377
|
+
|
|
378
|
+
### **File Pattern Matching**
|
|
379
|
+
|
|
380
|
+
Use wildcards to process multiple files matching a pattern:
|
|
381
|
+
|
|
382
|
+
```json
|
|
383
|
+
{
|
|
384
|
+
"settings": {
|
|
385
|
+
"fileKey": "sales_%.csv",
|
|
386
|
+
"fileType": "CSV"
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
This processes files like:
|
|
392
|
+
|
|
393
|
+
- `sales_2023.csv`
|
|
394
|
+
- `sales_2024.csv`
|
|
395
|
+
- `sales_january.csv`
|
|
396
|
+
|
|
397
|
+
### **Compressed Files**
|
|
398
|
+
|
|
399
|
+
Set `compressionType` to have Remora decompress the source file before parsing. Supported values: `GZ`, `ZIP`, `TAR`. The `fileType` still describes the *decompressed* content.
|
|
400
|
+
|
|
401
|
+
```json
|
|
402
|
+
{
|
|
403
|
+
"settings": {
|
|
404
|
+
"fileKey": "events.jsonl.gz",
|
|
405
|
+
"fileType": "JSONL",
|
|
406
|
+
"compressionType": "GZ"
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
`GZ` wraps a single file. `ZIP` and `TAR` are archives and may hold several: every file inside is read, so one `fileKey` naming an archive produces the same result as a wildcard `fileKey` matching those files individually. Every member must therefore be the same `fileType` and share the same shape, exactly as the files behind a wildcard must.
|
|
412
|
+
|
|
413
|
+
```json
|
|
414
|
+
{
|
|
415
|
+
"settings": {
|
|
416
|
+
"fileKey": "daily_exports.zip",
|
|
417
|
+
"fileType": "CSV",
|
|
418
|
+
"delimiter": ",",
|
|
419
|
+
"hasHeaderRow": true,
|
|
420
|
+
"compressionType": "ZIP"
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Archives are decompressed a member at a time, streamed to disk rather than held in memory, so the size of the archive and of the largest file in it are not bound by the worker's memory. A single archive may hold up to 10 000 files; an encrypted member is refused.
|
|
426
|
+
|
|
427
|
+
A file that cannot be decompressed fails the run naming the file and the declared format, so a producer staging hundreds of files through a wildcard `fileKey` says which one to go and look at. Three cases are worth knowing apart, because the compression libraries report the first two identically:
|
|
428
|
+
|
|
429
|
+
| What is wrong | What the run says |
|
|
430
|
+
| --- | --- |
|
|
431
|
+
| The file is empty (0 bytes) | `Source file "..." is empty (0 bytes), so there is no GZ archive in it to read` |
|
|
432
|
+
| The file is a real archive that stops mid-stream | `Failed to decompress "..." as GZ: unexpected end of file. The archive ends mid-stream, so it is truncated` |
|
|
433
|
+
| The file is not that format at all | `Source file "..." is declared as GZ but does not start with the GZ signature 1f 8b` |
|
|
434
|
+
|
|
435
|
+
The first two are what a **drop folder** produces when a run reads a file while something else is still writing it: the file exists at zero bytes before it holds anything, and holds a partial archive until the write completes. Nothing on Remora's side can tell that file from a finished one, so have the writer stage it under a temporary name and rename it into place once written — a rename within a filesystem is atomic, so a run either sees the whole file or does not see it at all.
|
|
436
|
+
|
|
437
|
+
The third is a configuration mistake: `compressionType` names a format the file is not in. Remora checks the file's leading bytes against the declared format before decompressing it (`1f 8b` for `GZ`, `50 4b` for `ZIP`), so this is refused by name rather than reported as whatever the decompressor made of the first chunk. `TAR` carries no signature at the start of the file, so a mislabelled `TAR` is reported by the extractor instead.
|
|
438
|
+
|
|
439
|
+
### **Excel Start Row / Column**
|
|
440
|
+
|
|
441
|
+
For Excel files, `startRow` and `startColumn` (both 1-indexed) let you skip leading rows/columns before the data begins:
|
|
442
|
+
|
|
443
|
+
```json
|
|
444
|
+
{
|
|
445
|
+
"settings": {
|
|
446
|
+
"fileKey": "report.xlsx",
|
|
447
|
+
"fileType": "XLSX",
|
|
448
|
+
"sheetName": "Data",
|
|
449
|
+
"startRow": 3,
|
|
450
|
+
"startColumn": 2
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### **Example Configurations**
|
|
456
|
+
|
|
457
|
+
**CSV Data Processing:**
|
|
458
|
+
|
|
459
|
+
```json
|
|
460
|
+
{
|
|
461
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
462
|
+
"name": "customer-data",
|
|
463
|
+
"description": "Customer information from CSV file",
|
|
464
|
+
"source": "local",
|
|
465
|
+
"settings": {
|
|
466
|
+
"fileKey": "customers.csv",
|
|
467
|
+
"fileType": "CSV",
|
|
468
|
+
"delimiter": ",",
|
|
469
|
+
"hasHeaderRow": true},
|
|
470
|
+
"dimensions": [
|
|
471
|
+
{
|
|
472
|
+
"name": "customer_id",
|
|
473
|
+
"type": "string",
|
|
474
|
+
"pk": true,
|
|
475
|
+
"description": "Unique customer identifier"
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
"name": "first_name",
|
|
479
|
+
"type": "string",
|
|
480
|
+
"classification": ["PII"],
|
|
481
|
+
"mask": "hash"
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
"name": "email",
|
|
485
|
+
"type": "string",
|
|
486
|
+
"classification": ["PII"],
|
|
487
|
+
"mask": "mask"
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
"name": "registration_date",
|
|
491
|
+
"type": "datetime"
|
|
492
|
+
}
|
|
493
|
+
],
|
|
494
|
+
"_version": 1
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
**JSON Lines Processing:**
|
|
499
|
+
|
|
500
|
+
```json
|
|
501
|
+
{
|
|
502
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
503
|
+
"name": "event-logs",
|
|
504
|
+
"description": "Application event logs",
|
|
505
|
+
"source": "s3-bucket",
|
|
506
|
+
"settings": {
|
|
507
|
+
"fileKey": "logs/events.jsonl",
|
|
508
|
+
"fileType": "JSONL"
|
|
509
|
+
},
|
|
510
|
+
"dimensions": [
|
|
511
|
+
{
|
|
512
|
+
"name": "timestamp",
|
|
513
|
+
"type": "datetime",
|
|
514
|
+
"description": "Event timestamp"
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
"name": "user_id",
|
|
518
|
+
"type": "string",
|
|
519
|
+
"classification": ["PII"]
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
"name": "event_type",
|
|
523
|
+
"type": "string"
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
"name": "session_id",
|
|
527
|
+
"type": "string"
|
|
528
|
+
}
|
|
529
|
+
],
|
|
530
|
+
"_version": 1
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## **SQL-Based Producers**
|
|
537
|
+
|
|
538
|
+
SQL-based producers work with database sources like Redshift, providing structured data access.
|
|
539
|
+
|
|
540
|
+
### **Configuration Schema**
|
|
541
|
+
|
|
542
|
+
```json
|
|
543
|
+
{
|
|
544
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
545
|
+
"name": "sql-producer",
|
|
546
|
+
"description": "SQL-based data producer",
|
|
547
|
+
"source": "redshift-warehouse",
|
|
548
|
+
"settings": {
|
|
549
|
+
"sqlTable": "customers",
|
|
550
|
+
"direct": true},
|
|
551
|
+
"dimensions": [/* field definitions */ ],
|
|
552
|
+
"_version": 1
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### **Settings Properties**
|
|
557
|
+
|
|
558
|
+
| Property | Required | Description | Example |
|
|
559
|
+
| --- | --- | --- | --- |
|
|
560
|
+
| `sqlTable` | ✅ | Database table name, `table` or `schema.table` | `"customers"`, `"analytics.sales_summary"` |
|
|
561
|
+
| `direct` | ❌ | Query the source directly as read-only (no view/DDL is created either way) | `true`, `false` (default) |
|
|
562
|
+
| `disablePushdown` | ❌ | Stop the run's filters and record limit being sent to the server as a `WHERE` and a `LIMIT`, so the whole table is read and narrowed locally. Both still apply either way — this only costs bandwidth | `true`, `false` (default) |
|
|
563
|
+
|
|
564
|
+
`sqlTable` is the only way to say where a SQL producer reads. A hand-written statement is not accepted: Remora builds the statement itself, from the table and the producer's own dimensions, so what a producer reads is always what it declares.
|
|
565
|
+
|
|
566
|
+
### **Filter and Limit Pushdown**
|
|
567
|
+
|
|
568
|
+
A consumer's filters, the filters an API or queue request carries, and the run's record limit (`remora run -l`) are sent to the server as part of the staging statement whenever Remora can guarantee they prune nothing the run would keep:
|
|
569
|
+
|
|
570
|
+
```sql
|
|
571
|
+
SELECT "id", "country", "score" FROM "public"."accounts" WHERE "country" IN ('Italy', 'Spain') LIMIT 100
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
This is an optimization and nothing else: every filter is applied per record and the limit enforced regardless, so a statement built without them reads more rows and returns the same result. Remora is deliberately conservative about what it pushes — the server is asked only for what it can be *proven* to agree with Remora about:
|
|
575
|
+
|
|
576
|
+
- The column's type in the database's own catalog must match the dimension's declared `type`, so the catalog is read once per producer when there is a filter to push. A `timestamptz` column declared as a `string` is read whole and filtered locally.
|
|
577
|
+
- Only `string` and `number` columns are pushed. A `datetime` bound names a different instant depending on how the value was parsed and on the session timezone, and a `boolean` is read back from a staged file differently than the server compares it.
|
|
578
|
+
- Only the operators that mean the same thing on both sides: `equals`, `in`, `contains`, `startsWith` and `endsWith` on text, and `equals`, `in`, `between` and the four comparisons on numbers. Every negation, null check and regular expression stays local.
|
|
579
|
+
- A filter on a field whose value is touched between the source and the filter — a transform, a default, a mask, a validation — is never pushed, since the record's value and the column's are no longer the same thing.
|
|
580
|
+
- The limit is pushed only when nothing between the source and the output can drop a record: no filters, `distinct`, `distinctOn`, `pivot`, `"onFail": "skip"` validation, producer `recordSelector` or row-fanning group.
|
|
581
|
+
|
|
582
|
+
Anything that does not qualify is simply read and narrowed locally, exactly as before. Set `"disablePushdown": true` on the producer to turn both off.
|
|
583
|
+
|
|
584
|
+
### **Direct Access**
|
|
585
|
+
|
|
586
|
+
Remora queries the SQL source directly — it builds `SELECT <dimensions> FROM <sqlTable>`, projecting the producer's dimensions in declaration order. Set `direct: true` to signal that Remora has read-only access to the source and should query it as-is (recommended for customer-cloud deployments where Remora holds read-only permissions).
|
|
587
|
+
|
|
588
|
+
```json
|
|
589
|
+
{
|
|
590
|
+
"settings": {
|
|
591
|
+
"sqlTable": "customer_orders",
|
|
592
|
+
"direct": true}
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### **Example Configurations**
|
|
597
|
+
|
|
598
|
+
**Simple Table Access:**
|
|
599
|
+
|
|
600
|
+
```json
|
|
601
|
+
{
|
|
602
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
603
|
+
"name": "claims-data",
|
|
604
|
+
"description": "Healthcare claims from data warehouse",
|
|
605
|
+
"source": "redshift-warehouse",
|
|
606
|
+
"settings": {
|
|
607
|
+
"sqlTable": "claims",
|
|
608
|
+
"direct": true},
|
|
609
|
+
"dimensions": [
|
|
610
|
+
{
|
|
611
|
+
"name": "claim_id",
|
|
612
|
+
"type": "string",
|
|
613
|
+
"pk": true,
|
|
614
|
+
"description": "Unique claim identifier"
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
"name": "member_id",
|
|
618
|
+
"type": "string",
|
|
619
|
+
"classification": ["PHI"],
|
|
620
|
+
"mask": "hash"
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
"name": "provider_id",
|
|
624
|
+
"type": "string"
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
"name": "service_date",
|
|
628
|
+
"type": "datetime"
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
"name": "amount",
|
|
632
|
+
"type": "number"
|
|
633
|
+
}
|
|
634
|
+
],
|
|
635
|
+
"_version": 1
|
|
636
|
+
}
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
## **Delta Share Producers**
|
|
642
|
+
|
|
643
|
+
Delta Share producers access shared datasets through the Delta Share protocol.
|
|
644
|
+
|
|
645
|
+
### **Configuration Schema**
|
|
646
|
+
|
|
647
|
+
```json
|
|
648
|
+
{
|
|
649
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
650
|
+
"name": "shared-data",
|
|
651
|
+
"description": "Data from Delta Share",
|
|
652
|
+
"source": "delta-share-source",
|
|
653
|
+
"settings": {
|
|
654
|
+
"sqlTable": "shared_table_name",
|
|
655
|
+
"fileType": "PARQUET"
|
|
656
|
+
},
|
|
657
|
+
"dimensions": [/* field definitions */ ],
|
|
658
|
+
"_version": 1
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### **Settings Properties**
|
|
663
|
+
|
|
664
|
+
| Property | Required | Description | Example |
|
|
665
|
+
| --- | --- | --- | --- |
|
|
666
|
+
| `sqlTable` | ✅ | Shared table name | `"customer_segments"`, `"market_data"` |
|
|
667
|
+
| `fileType` | ❌ | Specify PARQUET for Delta tables | `"PARQUET"` |
|
|
668
|
+
| `disableHistory` | ❌ | Skip sending the current table version in the Delta Share query request | `true`, `false` (default) |
|
|
669
|
+
| `disablePushdown` | ❌ | Stop the run's filters and record limit being sent to the source as query hints, so the whole table is read and narrowed locally. Both still apply either way — this only costs bandwidth | `true`, `false` (default) |
|
|
670
|
+
|
|
671
|
+
### **Example Configuration**
|
|
672
|
+
|
|
673
|
+
```json
|
|
674
|
+
{
|
|
675
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
676
|
+
"name": "shared-claims",
|
|
677
|
+
"description": "Shared healthcare claims data",
|
|
678
|
+
"source": "partner-delta-share",
|
|
679
|
+
"settings": {
|
|
680
|
+
"sqlTable": "claim_share_external",
|
|
681
|
+
"fileType": "PARQUET"
|
|
682
|
+
},
|
|
683
|
+
"dimensions": [
|
|
684
|
+
{
|
|
685
|
+
"name": "hvid",
|
|
686
|
+
"type": "number",
|
|
687
|
+
"pk": true,
|
|
688
|
+
"description": "Health plan member ID"
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
"name": "claim_id",
|
|
692
|
+
"type": "number",
|
|
693
|
+
"description": "Unique claim identifier"
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
"name": "service_date",
|
|
697
|
+
"type": "datetime",
|
|
698
|
+
"description": "Date of service"
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
"name": "diagnosis_code",
|
|
702
|
+
"type": "string",
|
|
703
|
+
"description": "Primary diagnosis code"
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
"name": "patient_state",
|
|
707
|
+
"type": "string",
|
|
708
|
+
"description": "Patient location state"
|
|
709
|
+
}
|
|
710
|
+
],
|
|
711
|
+
"_version": 1
|
|
712
|
+
}
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
---
|
|
716
|
+
|
|
717
|
+
## **HTTP API Producers**
|
|
718
|
+
|
|
719
|
+
HTTP API producers read data from a REST endpoint exposed by an `http-api` source. The `fileKey` holds the endpoint path, and `fileType` describes the response body (usually `JSON`).
|
|
720
|
+
|
|
721
|
+
### **Settings Properties**
|
|
722
|
+
|
|
723
|
+
| Property | Required | Description | Example |
|
|
724
|
+
| --- | --- | --- | --- |
|
|
725
|
+
| `fileKey` | ✅ | The API endpoint path (appended to the source's base URL) | `"/api/v1/users"` |
|
|
726
|
+
| `fileType` | ✅ | The format of the response body | `"JSON"` |
|
|
727
|
+
| `httpApi.dataProperty` | ❌ | If the records are wrapped in a property of the response object, its name | `"result"`, `"data"` |
|
|
728
|
+
|
|
729
|
+
### **Example Configuration**
|
|
730
|
+
|
|
731
|
+
If the API returns `{ "size": 10, "success": true, "result": [ ... ] }`, point `dataProperty` at `result` so Remora reads the array inside it:
|
|
732
|
+
|
|
733
|
+
```json
|
|
734
|
+
{
|
|
735
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
736
|
+
"name": "api-users",
|
|
737
|
+
"description": "User data from a REST API",
|
|
738
|
+
"source": "rest-api-source",
|
|
739
|
+
"settings": {
|
|
740
|
+
"fileKey": "/api/v1/users",
|
|
741
|
+
"fileType": "JSON",
|
|
742
|
+
"httpApi": {
|
|
743
|
+
"dataProperty": "result"
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
"dimensions": [
|
|
747
|
+
{ "name": "user_id", "type": "string", "pk": true },
|
|
748
|
+
{ "name": "username", "type": "string" },
|
|
749
|
+
{ "name": "email", "type": "string", "classification": ["PII"], "mask": "mask" }
|
|
750
|
+
],
|
|
751
|
+
"_version": 1
|
|
752
|
+
}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
---
|
|
756
|
+
|
|
757
|
+
## **Dimensions (Fields)**
|
|
758
|
+
|
|
759
|
+
Dimensions define the structure and characteristics of data fields in your producer.
|
|
760
|
+
|
|
761
|
+
### **Dimension Schema**
|
|
762
|
+
|
|
763
|
+
```json
|
|
764
|
+
{
|
|
765
|
+
"name": "field_name",
|
|
766
|
+
"description": "Field description",
|
|
767
|
+
"type": "string",
|
|
768
|
+
"alias": "source_column_name",
|
|
769
|
+
"pk": false,
|
|
770
|
+
"classification": ["PII"],
|
|
771
|
+
"mask": "hash"
|
|
772
|
+
}
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
### **Properties Reference**
|
|
776
|
+
|
|
777
|
+
| Property | Required | Description | Values |
|
|
778
|
+
| --- | --- | --- | --- |
|
|
779
|
+
| `name` | ✅ | Field name in Remora | Any valid identifier |
|
|
780
|
+
| `description` | ❌ | Field description. No runtime effect, but it is shown under the dimension in `remora ui-preview` and the field filter matches it | Descriptive text |
|
|
781
|
+
| `type` | ✅ | Data type. Omitted only on a repeating group, which produces no column of its own | `string`, `number`, `datetime`, `boolean` |
|
|
782
|
+
| `alias` | ❌ | Source column/field name | Source-specific field name |
|
|
783
|
+
| `pk` | ❌ | Primary key indicator | `true`, `false` (default) |
|
|
784
|
+
| `nullable` | ❌ | Whether the field may hold null/missing values. **Defaults to `true`**: a missing value (including the empty field a SQL `NULL` is staged as) is kept as `null`. Set `false` to get the type's own empty value instead — `""` for `string`, `0` for `number`, `false` for `boolean`; a `datetime` has none and stays `null`. To reject a missing value rather than fill it, use a consumer field `required` validation. | `true`, `false` |
|
|
785
|
+
| `classification` | ❌ | Data privacy classification | `["PII"]`, `["PHI"]`, `["GDPR"]` |
|
|
786
|
+
| `mask` | ❌ | Data masking method | `hash`, `mask`, `crypt`, `random`, `seeded-random`, `none` |
|
|
787
|
+
| `format` | ❌ | Source parse/cast format (e.g. a date format). `DDD` reads a day-of-year ("Julian") date | `"YYYYMMDD"`, `"DD-MM-YYYY"`, `"YYYYDDD"` |
|
|
788
|
+
| `sourceFilename` | ❌ | Populate this dimension with the source file name (file producers only; one per producer) | `true`, `false` (default) |
|
|
789
|
+
| `position` | ⚠️ | Where the value sits in the line, for `FIXED` (fixed-width) producers — required on every dimension of one, ignored elsewhere. See [Fixed-Width Files](#fixed-width-files-fixed). | `{ "start": 21, "length": 10 }`, `{ "start": 21, "end": 30 }` |
|
|
790
|
+
| `position.after` | ❌ | Anchors the position to the end of an earlier group's actual occurrences instead of to the start of the line. See [Variable Occurrences](#variable-occurrences-and-anchoring). | `{ "after": "reimb", "length": 18 }` |
|
|
791
|
+
| `dimensions` | ❌ | Makes this a repeating group (`FIXED` only): the fields that repeat, positioned relative to one occurrence. See [Repeating Groups](#repeating-groups-occurs). | see Repeating Groups |
|
|
792
|
+
| `occurs` | ❌ | Occurrences of a repeating group. Default `1`, max `1000`; an object when the count varies per line | `3`, `{ "max": 36, "while": { ... } }` |
|
|
793
|
+
| `indexFrom` | ❌ | First index used in the names a group generates | `0` (default), `1` |
|
|
794
|
+
| `naming` | ❌ | Name template for the dimensions a group or `flags` generates | `"{group}_{index}_{field}"`, `"{field}_{flag}"` |
|
|
795
|
+
| `emit` | ❌ | Whether a group's occurrences become columns (default) or one record each. See [Occurrences as Rows](#occurrences-as-rows). | `"columns"`, `"rows"` |
|
|
796
|
+
| `occurrenceField` | ❌ | Name of the occurrence-index field an `emit: "rows"` group generates. Default `{group}Occurrence` | `"addr_occurrence"` |
|
|
797
|
+
| `skipEmptyOccurrences` | ❌ | Whether an `emit: "rows"` group drops all-empty occurrences. Default `true` | `false` |
|
|
798
|
+
| `flags` | ❌ | Decodes a per-character bitmap into one dimension per character (`FIXED` only). See [Flag Bitmaps](#flag-bitmaps). | `["mailing", "billing", "physical"]` |
|
|
799
|
+
| `synth` | ❌ | Synthetic-data configuration for this dimension, used only by `synth`. See [Synthetic data properties](#synthetic-data-properties) below. | `{ "decimals": 0 }` |
|
|
800
|
+
|
|
801
|
+
### **Synthetic Data Properties**
|
|
802
|
+
|
|
803
|
+
Everything that shapes a dimension's *generated* values lives under `synth`. None of it affects a real
|
|
804
|
+
producer read — it only applies when you run `synth`:
|
|
805
|
+
|
|
806
|
+
```json
|
|
807
|
+
{
|
|
808
|
+
"name": "gap_status",
|
|
809
|
+
"type": "string",
|
|
810
|
+
"synth": { "codeSet": "gap_statuses" }
|
|
811
|
+
}
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
| Property | Required | Description | Values |
|
|
815
|
+
| --- | --- | --- | --- |
|
|
816
|
+
| `synth.decimals` | ❌ | Decimal places for generated `number` values. `0` forces whole integers (loads into an `integer` column); a positive value rounds to that many decimals. | `0`, `4` |
|
|
817
|
+
| `synth.min` / `synth.max` | ❌ | Inclusive bounds for generated values — a number range, a date window for a `datetime`, or a length in characters for a `string`. Either can be given alone. Works on key/ID columns too. Not for `boolean` dimensions, and not combined with `allowedValues`/`codeSet`/`references`. | `{ "min": 18, "max": 90 }`, `{ "min": "2024-01-01", "max": "2024-12-31" }`, `{ "max": 20 }` |
|
|
818
|
+
| `synth.unique` | ❌ | Emit a distinct value in every row, for a column that must not repeat without being the primary key. Generated as an identifier from the key pool, not from the column's topic. `string`/`number` dimensions only, and not combined with `allowedValues`/`codeSet`/`references`. | `true` |
|
|
819
|
+
| `synth.allowedValues` | ❌ | Inline closed set of values. See [Code Sets](Remora%20-%20Code%20Sets.md). Mutually exclusive with `synth.codeSet`. | e.g. `["Y", "N"]` |
|
|
820
|
+
| `synth.codeSet` | ❌ | Reference to a named [code set](Remora%20-%20Code%20Sets.md). Mutually exclusive with `synth.allowedValues`. | `"gap_statuses"` or `{ "name": "hedis_measures", "field": "code" }` |
|
|
821
|
+
| `synth.references` | ❌ | Declares this dimension as a foreign key into another producer's field. Generated values are drawn from the referenced field's key domain so joins/unions line up. Single-column only. Mutually exclusive with `synth.allowedValues`/`synth.codeSet`. | `{ "producer": "p_members", "field": "member_id" }` |
|
|
822
|
+
|
|
823
|
+
> These properties used to sit directly on the dimension (`"codeSet": "gap_statuses"`). They must now be
|
|
824
|
+
> nested under `synth`; a leftover top-level property is rejected with an error telling you to move it.
|
|
825
|
+
|
|
826
|
+
### **Data Types**
|
|
827
|
+
|
|
828
|
+
### **String**
|
|
829
|
+
|
|
830
|
+
```json
|
|
831
|
+
{
|
|
832
|
+
"name": "customer_name",
|
|
833
|
+
"type": "string",
|
|
834
|
+
"description": "Customer full name"
|
|
835
|
+
}
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
For synthetic data (`synth`), `synth.min` / `synth.max` are the value's **length in characters**, both ends inclusive, and either can be given alone. On a string the bounds *clamp* the generated value rather than replace it — a value longer than `max` is cut to it, a value shorter than `min` is filled out with letters — so a column keeps generating plausible names or cities while still fitting the width it has to load into:
|
|
839
|
+
|
|
840
|
+
```json
|
|
841
|
+
{
|
|
842
|
+
"name": "member_last_name",
|
|
843
|
+
"type": "string",
|
|
844
|
+
"synth": { "max": 20 }
|
|
845
|
+
}
|
|
846
|
+
{
|
|
847
|
+
"name": "external_ref",
|
|
848
|
+
"type": "string",
|
|
849
|
+
"synth": { "min": 8, "max": 8 }
|
|
850
|
+
}
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
Lengths must be whole numbers (`min` at least 0, `max` at least 1). A cut value is trimmed, so it never ends on a space.
|
|
854
|
+
|
|
855
|
+
#### Key and ID columns
|
|
856
|
+
|
|
857
|
+
A key column — a primary key, or one whose name reads like an identifier (`member_id`, `account_key`, …) — is not generated from a topic. Its values come from a shared pool, so the same key name means the same value domain in every producer that has it and datasets relate to each other. Bounds work there too, and mean the same thing, but they are applied differently: the pool is **generated at** the declared width instead of being clamped to it, because cutting `member_id_10` and `member_id_100` down to a width would make them the same value and break the relationships the pool exists to keep.
|
|
858
|
+
|
|
859
|
+
```json
|
|
860
|
+
{
|
|
861
|
+
"name": "member_id",
|
|
862
|
+
"type": "string",
|
|
863
|
+
"pk": true,
|
|
864
|
+
"synth": { "min": 10, "max": 10 }
|
|
865
|
+
}
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
That generates `memberid01`, `memberid02`, … — a name-derived prefix plus a sequence number, together exactly 10 characters. The prefix gives way to the digits when the width is tight, so a narrow key still gets a distinct value per row. When both ends are given the pool is generated at `max`, so the value fills the column that declared it.
|
|
869
|
+
|
|
870
|
+
On a `number` key, `min`/`max` are the value range as usual and the pool is taken from the start of it: `{ "min": 500000, "max": 999999 }` yields `500000`, `500001`, …
|
|
871
|
+
|
|
872
|
+
A **primary key** (`"pk": true`) walks that pool by row, so every row gets its own value. Any other key draws from it at random, which is what makes a `customer_id` repeat across rows the way a real one does. When a column has to be distinct without being the primary key — a natural key, an external reference, an account number — declare `synth.unique`:
|
|
873
|
+
|
|
874
|
+
```json
|
|
875
|
+
{
|
|
876
|
+
"name": "external_ref",
|
|
877
|
+
"type": "string",
|
|
878
|
+
"synth": { "unique": true }
|
|
879
|
+
}
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
That column is then served from the pool too (`external_ref_1`, `external_ref_2`, …) and walked by row, so no two rows share a value. Note what it costs: a unique column is generated as an *identifier*, not from its name — uniqueness is not something a name-based generator can promise, so `synth.unique` on `email` gives you `email_1`, not a realistic address. It applies to `string` and `number` columns, combines with `min`/`max` the way any key does, and is rejected on a column whose values come from an `allowedValues`/`codeSet` list (those repeat by definition) or from `references` (a unique foreign key is a one-to-one relation, which `synth` does not generate yet).
|
|
883
|
+
|
|
884
|
+
Because the pool holds one value per generated record, a run that asks for more records than the pool can hold (100,000) while something has to stay distinct fails with an error rather than quietly repeating values.
|
|
885
|
+
|
|
886
|
+
Two rules follow from the pool being shared, and both fail with an error rather than quietly producing something wrong:
|
|
887
|
+
|
|
888
|
+
- **The width has to hold the pool.** The pool has one value per generated record (`--records`), so `{ "max": 3 }` cannot serve 5,000 rows. Widen the column or generate fewer records.
|
|
889
|
+
- **Same-named keys must agree.** If two producers both have `member_id` and declare different bounds for it, there is no single domain left to share.
|
|
890
|
+
|
|
891
|
+
Bound the **parent** field of a foreign key, never the child: a `synth.references` column's values *are* the parent's, so it inherits the parent's width automatically.
|
|
892
|
+
|
|
893
|
+
### **Number**
|
|
894
|
+
|
|
895
|
+
```json
|
|
896
|
+
{
|
|
897
|
+
"name": "transaction_amount",
|
|
898
|
+
"type": "number",
|
|
899
|
+
"description": "Transaction amount in dollars"
|
|
900
|
+
}
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
For synthetic data (`synth`), use `synth.decimals` to control how many decimal places generated `number` values get, so they load into the destination column. `decimals: 0` forces whole integers (fits an `integer` column); a positive value rounds to that many decimals:
|
|
904
|
+
|
|
905
|
+
```json
|
|
906
|
+
{
|
|
907
|
+
"name": "readmission_probability",
|
|
908
|
+
"type": "number",
|
|
909
|
+
"synth": { "decimals": 0 }
|
|
910
|
+
}
|
|
911
|
+
{
|
|
912
|
+
"name": "days_covered_percentage",
|
|
913
|
+
"type": "number",
|
|
914
|
+
"synth": { "decimals": 4 }
|
|
915
|
+
}
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
Use `synth.min` / `synth.max` to pin the range a generated `number` is drawn from — both ends inclusive, and either one can be given alone. Bounds replace the name-based generator, so this is how you keep a column inside a real-world range the field name can't imply (a plausible age, a rate between 0 and 1, an amount that has to fit a fixed-width column):
|
|
919
|
+
|
|
920
|
+
```json
|
|
921
|
+
{
|
|
922
|
+
"name": "member_age",
|
|
923
|
+
"type": "number",
|
|
924
|
+
"synth": { "min": 18, "max": 90, "decimals": 0 }
|
|
925
|
+
}
|
|
926
|
+
{
|
|
927
|
+
"name": "risk_factor",
|
|
928
|
+
"type": "number",
|
|
929
|
+
"synth": { "min": 0.5, "max": 2.5, "decimals": 3 }
|
|
930
|
+
}
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
Values are whole numbers unless `decimals` asks otherwise. Bounds apply to `number`, `datetime` and `string` dimensions (never `boolean`), and not to a dimension whose value already comes from somewhere else — `allowedValues`, `codeSet` or `references` — where they would be silently ignored; `synth` fails with an error naming the dimension instead.
|
|
934
|
+
|
|
935
|
+
### **DateTime**
|
|
936
|
+
|
|
937
|
+
```json
|
|
938
|
+
{
|
|
939
|
+
"name": "created_at",
|
|
940
|
+
"type": "datetime",
|
|
941
|
+
"description": "Record creation timestamp"
|
|
942
|
+
}
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
For synthetic data (`synth`), `synth.min` / `synth.max` set the window dates are drawn from, instead of the default 2020–2024 one. Write them the way the column's own values are written: with the dimension's `format` if it has one, otherwise as a plain or ISO date. Either bound can be given alone, and the other then spans the default window's width from it:
|
|
946
|
+
|
|
947
|
+
```json
|
|
948
|
+
{
|
|
949
|
+
"name": "measurement_year_start",
|
|
950
|
+
"type": "datetime",
|
|
951
|
+
"synth": { "min": "2024-01-01", "max": "2024-12-31" }
|
|
952
|
+
}
|
|
953
|
+
{
|
|
954
|
+
"name": "claim_paid_date",
|
|
955
|
+
"type": "datetime",
|
|
956
|
+
"format": "YYYYMMDD",
|
|
957
|
+
"synth": { "min": "20240101", "max": "20240131" }
|
|
958
|
+
}
|
|
959
|
+
{
|
|
960
|
+
"name": "event_at",
|
|
961
|
+
"type": "datetime",
|
|
962
|
+
"synth": { "min": "2025-06-01T08:00:00Z", "max": "2025-06-01T17:00:00Z" }
|
|
963
|
+
}
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
A bound that names a day covers that whole day, so the `20240101`–`20240131` window above generates every date in January including the 31st. Give a time (as in the third example) when you want the window to end at a precise instant. Dates are parsed and rendered in UTC, so a window means the same thing on every machine.
|
|
967
|
+
|
|
968
|
+
### **Boolean**
|
|
969
|
+
|
|
970
|
+
```json
|
|
971
|
+
{
|
|
972
|
+
"name": "is_active",
|
|
973
|
+
"type": "boolean",
|
|
974
|
+
"description": "Whether the record is active"
|
|
975
|
+
}
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
### **Field Aliases**
|
|
979
|
+
|
|
980
|
+
Use aliases when source field names differ from desired Remora field names:
|
|
981
|
+
|
|
982
|
+
```json
|
|
983
|
+
{
|
|
984
|
+
"name": "customer_id",
|
|
985
|
+
"alias": "cust_id",
|
|
986
|
+
"type": "string",
|
|
987
|
+
"description": "Maps cust_id column to customer_id field"
|
|
988
|
+
}
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
### **Primary Keys**
|
|
992
|
+
|
|
993
|
+
Mark fields that uniquely identify records:
|
|
994
|
+
|
|
995
|
+
```json
|
|
996
|
+
{
|
|
997
|
+
"name": "transaction_id",
|
|
998
|
+
"type": "string",
|
|
999
|
+
"pk": true,
|
|
1000
|
+
"description": "Unique transaction identifier"
|
|
1001
|
+
}
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
### **Source Format**
|
|
1005
|
+
|
|
1006
|
+
When the source represents a value in a format Remora can't infer (most often dates), set `format` to tell Remora how to parse it while casting to the dimension's `type`:
|
|
1007
|
+
|
|
1008
|
+
```json
|
|
1009
|
+
{
|
|
1010
|
+
"name": "service_date",
|
|
1011
|
+
"type": "datetime",
|
|
1012
|
+
"format": "YYYYMMDD"
|
|
1013
|
+
}
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
### **Source Filename**
|
|
1017
|
+
|
|
1018
|
+
Set `sourceFilename: true` on a dimension to populate it with the name of the file each record came from. This is especially useful with wildcard `fileKey` patterns that merge many files into one dataset. Only one dimension per producer can use it, and only for file-based sources (`local`, `aws-s3`). The dimension does not need to exist in the source data.
|
|
1019
|
+
|
|
1020
|
+
```json
|
|
1021
|
+
{
|
|
1022
|
+
"name": "source_file",
|
|
1023
|
+
"type": "string",
|
|
1024
|
+
"sourceFilename": true
|
|
1025
|
+
}
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
### **Value Constraints (Code Sets)**
|
|
1029
|
+
|
|
1030
|
+
For **synthetic data generation** (the `synth` command), you can constrain a dimension to a closed set of values — inline with `synth.allowedValues`, or by referencing a reusable, named **code set** with `synth.codeSet`. Code sets also keep related fields coherent (e.g. a measure code and its description on the same row). Like everything under `synth`, these only affect `synth`; they have no effect on normal producer reads.
|
|
1031
|
+
|
|
1032
|
+
```json
|
|
1033
|
+
{
|
|
1034
|
+
"name": "gender_code",
|
|
1035
|
+
"type": "string",
|
|
1036
|
+
"synth": { "allowedValues": ["M", "F", "U"] }
|
|
1037
|
+
}
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
See [Remora - Code Sets](Remora%20-%20Code%20Sets.md) for the full feature, including structured (correlated) code sets and the built-in healthcare sets.
|
|
1041
|
+
|
|
1042
|
+
---
|
|
1043
|
+
|
|
1044
|
+
## **Data Classification and Masking**
|
|
1045
|
+
|
|
1046
|
+
Protect sensitive data with classification and masking controls.
|
|
1047
|
+
|
|
1048
|
+
### **Data Classifications**
|
|
1049
|
+
|
|
1050
|
+
### **Personally Identifiable Information (PII)**
|
|
1051
|
+
|
|
1052
|
+
```json
|
|
1053
|
+
{
|
|
1054
|
+
"name": "social_security_number",
|
|
1055
|
+
"type": "string",
|
|
1056
|
+
"classification": ["PII"],
|
|
1057
|
+
"mask": "hash"
|
|
1058
|
+
}
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
### **Protected Health Information (PHI)**
|
|
1062
|
+
|
|
1063
|
+
```json
|
|
1064
|
+
{
|
|
1065
|
+
"name": "patient_id",
|
|
1066
|
+
"type": "string",
|
|
1067
|
+
"classification": ["PHI"],
|
|
1068
|
+
"mask": "crypt"
|
|
1069
|
+
}
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
### **GDPR Protected Data**
|
|
1073
|
+
|
|
1074
|
+
```json
|
|
1075
|
+
{
|
|
1076
|
+
"name": "email_address",
|
|
1077
|
+
"type": "string",
|
|
1078
|
+
"classification": ["GDPR", "PII"],
|
|
1079
|
+
"mask": "mask"
|
|
1080
|
+
}
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
### **Multiple Classifications**
|
|
1084
|
+
|
|
1085
|
+
```json
|
|
1086
|
+
{
|
|
1087
|
+
"name": "medical_record_number",
|
|
1088
|
+
"type": "string",
|
|
1089
|
+
"classification": ["PII", "PHI", "GDPR"],
|
|
1090
|
+
"mask": "hash"
|
|
1091
|
+
}
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
### **Masking Methods**
|
|
1095
|
+
|
|
1096
|
+
### **Hash Masking**
|
|
1097
|
+
|
|
1098
|
+
- **Purpose**: One-way transformation for analytics
|
|
1099
|
+
- **Use case**: Customer IDs, account numbers
|
|
1100
|
+
- **Reversible**: No
|
|
1101
|
+
|
|
1102
|
+
```json
|
|
1103
|
+
{
|
|
1104
|
+
"name": "customer_id",
|
|
1105
|
+
"type": "string",
|
|
1106
|
+
"classification": ["PII"],
|
|
1107
|
+
"mask": "hash"
|
|
1108
|
+
}
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
### **Field Masking**
|
|
1112
|
+
|
|
1113
|
+
- **Purpose**: Partial data hiding
|
|
1114
|
+
- **Use case**: Email addresses, phone numbers
|
|
1115
|
+
- **Reversible**: No
|
|
1116
|
+
|
|
1117
|
+
```json
|
|
1118
|
+
{
|
|
1119
|
+
"name": "email",
|
|
1120
|
+
"type": "string",
|
|
1121
|
+
"classification": ["PII"],
|
|
1122
|
+
"mask": "mask"
|
|
1123
|
+
}
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
### **Encryption**
|
|
1127
|
+
|
|
1128
|
+
- **Purpose**: Secure, reversible protection
|
|
1129
|
+
- **Use case**: Highly sensitive data
|
|
1130
|
+
- **Reversible**: Yes (with proper keys)
|
|
1131
|
+
|
|
1132
|
+
```json
|
|
1133
|
+
{
|
|
1134
|
+
"name": "medical_record",
|
|
1135
|
+
"type": "string",
|
|
1136
|
+
"classification": ["PHI"],
|
|
1137
|
+
"mask": "crypt"
|
|
1138
|
+
}
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
### **Random Replacement**
|
|
1142
|
+
|
|
1143
|
+
- **Purpose**: Replace with random values
|
|
1144
|
+
- **Use case**: Testing and development
|
|
1145
|
+
- **Reversible**: No
|
|
1146
|
+
|
|
1147
|
+
```json
|
|
1148
|
+
{
|
|
1149
|
+
"name": "phone_number",
|
|
1150
|
+
"type": "string",
|
|
1151
|
+
"classification": ["PII"],
|
|
1152
|
+
"mask": "random"
|
|
1153
|
+
}
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
### **Seeded Random**
|
|
1157
|
+
|
|
1158
|
+
- **Purpose**: Consistent random values
|
|
1159
|
+
- **Use case**: Repeatable test data
|
|
1160
|
+
- **Reversible**: No
|
|
1161
|
+
|
|
1162
|
+
```json
|
|
1163
|
+
{
|
|
1164
|
+
"name": "address",
|
|
1165
|
+
"type": "string",
|
|
1166
|
+
"classification": ["PII"],
|
|
1167
|
+
"mask": "seeded-random"
|
|
1168
|
+
}
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
### **No Masking**
|
|
1172
|
+
|
|
1173
|
+
- **Purpose**: Explicitly disable masking
|
|
1174
|
+
- **Use case**: Public data, overriding defaults
|
|
1175
|
+
|
|
1176
|
+
```json
|
|
1177
|
+
{
|
|
1178
|
+
"name": "product_name",
|
|
1179
|
+
"type": "string",
|
|
1180
|
+
"mask": "none"
|
|
1181
|
+
}
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
---
|
|
1185
|
+
|
|
1186
|
+
## **Schema Discovery**
|
|
1187
|
+
|
|
1188
|
+
Remora can automatically discover field schemas from your data sources.
|
|
1189
|
+
|
|
1190
|
+
### **Auto-Discovery Command**
|
|
1191
|
+
|
|
1192
|
+
```bash
|
|
1193
|
+
# Discover the schema and merge the fields into the producer's dimensions
|
|
1194
|
+
remora discover producer-name
|
|
1195
|
+
|
|
1196
|
+
# Rewrite the dimensions from the source instead of merging into them
|
|
1197
|
+
remora discover producer-name --replace
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
Where the fields come from depends on the source:
|
|
1201
|
+
|
|
1202
|
+
- **File sources** (local, S3, Delta Share) have no schema to ask for, so discovery reads a sample of the data and infers each field's type from its values.
|
|
1203
|
+
- **SQL sources** (Redshift, Postgres) declare their own schema, so discovery reads the table's columns from `information_schema` — the table named in `settings.sqlTable`, not the dimensions the producer already declares. It therefore finds the columns you have **not** declared yet, which is the point of running it, and it types them from the column's SQL type rather than from a handful of values: a `numeric` column is a `number` even if every row sampled from it happened to be whole. No data is read at all.
|
|
1204
|
+
|
|
1205
|
+
### **What Discovery Writes**
|
|
1206
|
+
|
|
1207
|
+
Discovery **merges** into the producer you already have. It never rewrites a dimension you wrote: re-running it on a curated producer is safe, and the only thing it changes about an existing dimension is to supply a property that dimension declared nothing for.
|
|
1208
|
+
|
|
1209
|
+
| What discovery found | What it does |
|
|
1210
|
+
| --- | --- |
|
|
1211
|
+
| A field the producer does not declare | Adds a dimension for it |
|
|
1212
|
+
| A dimension that declares no `nullable`, where the source states the column is `NOT NULL` | Fills in `"nullable": false` |
|
|
1213
|
+
| A dimension whose declared `type` is not the one the source reports | **Leaves it alone** and prints the difference |
|
|
1214
|
+
| A dimension the source has no field for | **Keeps it** and prints it |
|
|
1215
|
+
|
|
1216
|
+
Everything else a dimension carries — `pk`, `description`, `alias`, `format`, `mask`, `classification`, `synth` — is yours, and is never touched. So is the rest of the producer: its `description` and `settings` are written back exactly as you left them.
|
|
1217
|
+
|
|
1218
|
+
A declared type is left alone on purpose. A producer is free to read a `timestamptz` as a `string` or a padded `char(n)` as one, and that decision is not discovery's to undo — it tells you about the disagreement and lets you decide.
|
|
1219
|
+
|
|
1220
|
+
A dimension that is matched by `alias` rather than by `name` is still recognised, including an `alias` that reaches into a field (`address.city` matches the field `address`), so a renamed or decomposed column is not re-added as a duplicate.
|
|
1221
|
+
|
|
1222
|
+
A **new** dimension is created as:
|
|
1223
|
+
|
|
1224
|
+
```json
|
|
1225
|
+
{
|
|
1226
|
+
"name": "field_name",
|
|
1227
|
+
"type": "string",
|
|
1228
|
+
"description": "Auto-mapped field: field_name"
|
|
1229
|
+
}
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1232
|
+
A column a SQL table declares as `NOT NULL` also gets `"nullable": false`, since that is something the catalog states and a sample cannot. A nullable column carries no declaration, because [`nullable` already defaults to `true`](#dimension-properties).
|
|
1233
|
+
|
|
1234
|
+
### **Rewriting Instead of Merging**
|
|
1235
|
+
|
|
1236
|
+
```bash
|
|
1237
|
+
remora discover producer-name --replace
|
|
1238
|
+
```
|
|
1239
|
+
|
|
1240
|
+
`--replace` rebuilds the dimension list from the source: a dimension the source has no field for is **dropped**, and a declared type the source disagrees with is **overwritten**. Everything hand-written on the dimensions goes with it, so this is for one situation — bootstrapping a producer created from the `create-producer` template, whose placeholder dimensions nothing else removes. It is also the only way to delete a dimension whose column is gone upstream.
|
|
1241
|
+
|
|
1242
|
+
Dimensions the source could never describe are kept even here: a [`sourceFilename`](#dimension-properties) dimension holds the name of the file it was read from, not a field of the data, so no discovery can speak for it.
|
|
1243
|
+
|
|
1244
|
+
### **Manual Schema Refinement**
|
|
1245
|
+
|
|
1246
|
+
After auto-discovery, manually refine the schema:
|
|
1247
|
+
|
|
1248
|
+
1. **Update data types** for better precision
|
|
1249
|
+
2. **Add classifications** for sensitive data
|
|
1250
|
+
3. **Configure masking** for privacy protection
|
|
1251
|
+
4. **Set primary keys** for unique identifiers
|
|
1252
|
+
5. **Add descriptions** for business context
|
|
1253
|
+
|
|
1254
|
+
All of it survives the next `remora discover` — that is what the merge above is for. Re-run discovery whenever the source gains a column.
|
|
1255
|
+
|
|
1256
|
+
**Before Refinement:**
|
|
1257
|
+
|
|
1258
|
+
```json
|
|
1259
|
+
{
|
|
1260
|
+
"name": "customer_email",
|
|
1261
|
+
"type": "string",
|
|
1262
|
+
"description": "Auto-mapped field: customer_email"
|
|
1263
|
+
}
|
|
1264
|
+
```
|
|
1265
|
+
|
|
1266
|
+
**After Refinement:**
|
|
1267
|
+
|
|
1268
|
+
```json
|
|
1269
|
+
{
|
|
1270
|
+
"name": "customer_email",
|
|
1271
|
+
"type": "string",
|
|
1272
|
+
"description": "Customer email address for communications",
|
|
1273
|
+
"classification": ["PII", "GDPR"],
|
|
1274
|
+
"mask": "mask"
|
|
1275
|
+
}
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
---
|
|
1279
|
+
|
|
1280
|
+
## **Validation and Testing**
|
|
1281
|
+
|
|
1282
|
+
### **Configuration Validation**
|
|
1283
|
+
|
|
1284
|
+
```bash
|
|
1285
|
+
# Compile & validate all resource configurations in the project
|
|
1286
|
+
remora compile
|
|
1287
|
+
|
|
1288
|
+
# Validate a single producer (checks its config against its source)
|
|
1289
|
+
remora verify producer-name
|
|
1290
|
+
```
|
|
1291
|
+
|
|
1292
|
+
### **Producer Testing**
|
|
1293
|
+
|
|
1294
|
+
```bash
|
|
1295
|
+
# Read a few records from the producer to inspect the extracted data
|
|
1296
|
+
remora sample producer-name
|
|
1297
|
+
|
|
1298
|
+
# Show a specific number of sample rows
|
|
1299
|
+
remora sample producer-name -s 10
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
The rows come back exactly as the producer reads them: dimensions renamed to their declared names,
|
|
1303
|
+
values cast to their declared types, repeating groups expanded, and any dimension carrying a `mask`
|
|
1304
|
+
already masked. A column declared `"mask": "hash"` is therefore shown hashed here, the same as in a
|
|
1305
|
+
run — sampling never shows a masked value in the clear.
|
|
1306
|
+
|
|
1307
|
+
### **Common Validation Errors**
|
|
1308
|
+
|
|
1309
|
+
| Error | Cause | Solution |
|
|
1310
|
+
| --- | --- | --- |
|
|
1311
|
+
| `Source not found` | Invalid source reference | Check source name exists |
|
|
1312
|
+
| `Invalid file type` | Unsupported file format | Use supported file types |
|
|
1313
|
+
| `Invalid dimension type` | Wrong data type | Use valid dimension types |
|
|
1314
|
+
|
|
1315
|
+
---
|
|
1316
|
+
|
|
1317
|
+
## **Performance Optimization**
|
|
1318
|
+
|
|
1319
|
+
### **File-Based Producers**
|
|
1320
|
+
|
|
1321
|
+
1. **Use appropriate file formats**
|
|
1322
|
+
- JSON Lines for streaming large datasets
|
|
1323
|
+
- CSV for simple tabular data
|
|
1324
|
+
- Parquet for analytics workloads
|
|
1325
|
+
2. **Optimize file patterns**
|
|
1326
|
+
- Use specific patterns to limit file scanning
|
|
1327
|
+
- Partition large datasets by date/category
|
|
1328
|
+
3. **Configure memory settings**
|
|
1329
|
+
- Adjust `MAX_ITEMS_IN_MEMORY` for large files
|
|
1330
|
+
- Use streaming for files exceeding memory limits
|
|
1331
|
+
|
|
1332
|
+
### **SQL-Based Producers**
|
|
1333
|
+
|
|
1334
|
+
1. **Use direct access** for simple queries
|
|
1335
|
+
|
|
1336
|
+
```json
|
|
1337
|
+
{
|
|
1338
|
+
"settings": {
|
|
1339
|
+
"direct": true,
|
|
1340
|
+
"sqlTable": "optimized_table"
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
```
|
|
1344
|
+
|
|
1345
|
+
2. **Index source tables** for better query performance
|
|
1346
|
+
3. **Limit data with WHERE clauses** in custom SQL
|
|
1347
|
+
|
|
1348
|
+
### **Memory Management**
|
|
1349
|
+
|
|
1350
|
+
Configure memory settings in your project configuration:
|
|
1351
|
+
|
|
1352
|
+
```json
|
|
1353
|
+
{
|
|
1354
|
+
"settings": {
|
|
1355
|
+
"MAX_ITEMS_IN_MEMORY": 100000,
|
|
1356
|
+
"SQL_MAX_QUERY_ROWS": 1000000,
|
|
1357
|
+
"INDICATIVE_THREAD_LINE_COUNT": 10000
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
```
|
|
1361
|
+
|
|
1362
|
+
---
|
|
1363
|
+
|
|
1364
|
+
## **Migration and Versioning**
|
|
1365
|
+
|
|
1366
|
+
### **Producer Versioning**
|
|
1367
|
+
|
|
1368
|
+
Update the `_version` field when making significant changes:
|
|
1369
|
+
|
|
1370
|
+
```json
|
|
1371
|
+
{
|
|
1372
|
+
"name": "customer-data",
|
|
1373
|
+
"_version": 2
|
|
1374
|
+
}
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
### **Schema Evolution**
|
|
1378
|
+
|
|
1379
|
+
When evolving producer schemas:
|
|
1380
|
+
|
|
1381
|
+
1. **Add new dimensions** at the end of the dimensions array
|
|
1382
|
+
2. **Mark deprecated fields** with descriptions
|
|
1383
|
+
3. **Use aliases** to maintain compatibility
|
|
1384
|
+
4. **Test thoroughly** before deploying changes
|
|
1385
|
+
|
|
1386
|
+
### **Backwards Compatibility**
|
|
1387
|
+
|
|
1388
|
+
Maintain backwards compatibility by:
|
|
1389
|
+
|
|
1390
|
+
```json
|
|
1391
|
+
{
|
|
1392
|
+
"dimensions": [
|
|
1393
|
+
{
|
|
1394
|
+
"name": "customer_id",
|
|
1395
|
+
"alias": "legacy_cust_id",
|
|
1396
|
+
"type": "string",
|
|
1397
|
+
"description": "Mapped from legacy system"
|
|
1398
|
+
}
|
|
1399
|
+
]
|
|
1400
|
+
}
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
---
|
|
1404
|
+
|
|
1405
|
+
## **Best Practices**
|
|
1406
|
+
|
|
1407
|
+
### **Naming Conventions**
|
|
1408
|
+
|
|
1409
|
+
- **Producer names**: Use descriptive, lowercase with hyphens (`customer-orders`)
|
|
1410
|
+
- **Dimension names**: Use snake_case (`customer_id`, `order_date`)
|
|
1411
|
+
|
|
1412
|
+
### **Security**
|
|
1413
|
+
|
|
1414
|
+
1. **Classify sensitive data** appropriately
|
|
1415
|
+
2. **Apply masking** to protected fields
|
|
1416
|
+
3. **Use least-privilege** source access
|
|
1417
|
+
4. **Document data lineage** in descriptions
|
|
1418
|
+
|
|
1419
|
+
### **Performance**
|
|
1420
|
+
|
|
1421
|
+
1. **Use direct access** when possible
|
|
1422
|
+
2. **Limit data scope** with filters
|
|
1423
|
+
3. **Index source tables** for better performance
|
|
1424
|
+
4. **Monitor query performance** in production
|
|
1425
|
+
|
|
1426
|
+
### **Documentation**
|
|
1427
|
+
|
|
1428
|
+
1. **Add meaningful descriptions** to all components
|
|
1429
|
+
2. **Explain data transformations** in comments
|
|
1430
|
+
3. **Maintain schema documentation** separately
|
|
1431
|
+
|
|
1432
|
+
### **Testing**
|
|
1433
|
+
|
|
1434
|
+
1. **Validate configurations** before deployment
|
|
1435
|
+
2. **Test with sample data** during development
|
|
1436
|
+
3. **Monitor data quality** in production
|
|
1437
|
+
4. **Set up alerts** for schema changes
|
|
1438
|
+
|
|
1439
|
+
---
|
|
1440
|
+
|
|
1441
|
+
## **Troubleshooting**
|
|
1442
|
+
|
|
1443
|
+
### **Common Issues**
|
|
1444
|
+
|
|
1445
|
+
**File Not Found:**
|
|
1446
|
+
|
|
1447
|
+
```
|
|
1448
|
+
Error: File 'data.csv' not found in source 'local'
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
- Verify file exists in source path
|
|
1452
|
+
- Check file name spelling and case sensitivity
|
|
1453
|
+
- Ensure source path is correct
|
|
1454
|
+
|
|
1455
|
+
**Invalid SQL Table:**
|
|
1456
|
+
|
|
1457
|
+
```
|
|
1458
|
+
Error: Table 'customers' does not exist
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
- Verify table name in database
|
|
1462
|
+
- Check schema permissions
|
|
1463
|
+
- Ensure source connection is valid
|
|
1464
|
+
|
|
1465
|
+
**Type Mismatch:**
|
|
1466
|
+
|
|
1467
|
+
```
|
|
1468
|
+
Error: Cannot convert 'abc' to number for field 'amount'
|
|
1469
|
+
```
|
|
1470
|
+
|
|
1471
|
+
- Review data types in source
|
|
1472
|
+
- Update dimension type or add data cleaning
|
|
1473
|
+
- Check for null/empty values
|
|
1474
|
+
|
|
1475
|
+
**Permission Denied:**
|
|
1476
|
+
|
|
1477
|
+
```
|
|
1478
|
+
Error: Insufficient permissions to create view
|
|
1479
|
+
```
|
|
1480
|
+
|
|
1481
|
+
- Use `"direct": true` for read-only access
|
|
1482
|
+
- Check database permissions
|
|
1483
|
+
- Verify source credentials
|
|
1484
|
+
|
|
1485
|
+
### **Debug Mode**
|
|
1486
|
+
|
|
1487
|
+
Enable detailed logging in project configuration:
|
|
1488
|
+
|
|
1489
|
+
```json
|
|
1490
|
+
{
|
|
1491
|
+
"settings": {
|
|
1492
|
+
"LOG_LEVEL": "DEBUG",
|
|
1493
|
+
"DEBUG_MODE": true}
|
|
1494
|
+
}
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
### **Support Resources**
|
|
1498
|
+
|
|
1499
|
+
- **Schema validation**: Automatic validation against JSON schemas
|
|
1500
|
+
- **CLI help**: Run `remora --help` for command documentation
|
|
1501
|
+
- **Error messages**: Include specific guidance for resolution
|
|
1502
|
+
- **Discovery tools**: Use `remora discover` for schema generation
|
|
1503
|
+
|
|
1504
|
+
---
|
|
1505
|
+
|
|
1506
|
+
## **Example Producer Library**
|
|
1507
|
+
|
|
1508
|
+
### **Simple CSV Producer**
|
|
1509
|
+
|
|
1510
|
+
```json
|
|
1511
|
+
{
|
|
1512
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
1513
|
+
"name": "sales-data",
|
|
1514
|
+
"description": "Daily sales transactions",
|
|
1515
|
+
"source": "local",
|
|
1516
|
+
"settings": {
|
|
1517
|
+
"fileKey": "sales.csv",
|
|
1518
|
+
"fileType": "CSV",
|
|
1519
|
+
"hasHeaderRow": true},
|
|
1520
|
+
"dimensions": [
|
|
1521
|
+
{
|
|
1522
|
+
"name": "transaction_id",
|
|
1523
|
+
"type": "string",
|
|
1524
|
+
"pk": true},
|
|
1525
|
+
{
|
|
1526
|
+
"name": "customer_id",
|
|
1527
|
+
"type": "string",
|
|
1528
|
+
"classification": ["PII"],
|
|
1529
|
+
"mask": "hash"
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
"name": "product_name",
|
|
1533
|
+
"type": "string"
|
|
1534
|
+
},
|
|
1535
|
+
{
|
|
1536
|
+
"name": "amount",
|
|
1537
|
+
"type": "number"
|
|
1538
|
+
},
|
|
1539
|
+
{
|
|
1540
|
+
"name": "sale_date",
|
|
1541
|
+
"type": "datetime"
|
|
1542
|
+
}
|
|
1543
|
+
],
|
|
1544
|
+
"_version": 1
|
|
1545
|
+
}
|
|
1546
|
+
```
|
|
1547
|
+
|
|
1548
|
+
### **Healthcare Claims Producer**
|
|
1549
|
+
|
|
1550
|
+
```json
|
|
1551
|
+
{
|
|
1552
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
1553
|
+
"name": "healthcare-claims",
|
|
1554
|
+
"description": "Healthcare claims from data warehouse",
|
|
1555
|
+
"source": "redshift-warehouse",
|
|
1556
|
+
"settings": {
|
|
1557
|
+
"sqlTable": "claims",
|
|
1558
|
+
"direct": true},
|
|
1559
|
+
"dimensions": [
|
|
1560
|
+
{
|
|
1561
|
+
"name": "claim_id",
|
|
1562
|
+
"type": "string",
|
|
1563
|
+
"pk": true,
|
|
1564
|
+
"description": "Unique claim identifier"
|
|
1565
|
+
},
|
|
1566
|
+
{
|
|
1567
|
+
"name": "member_id",
|
|
1568
|
+
"type": "string",
|
|
1569
|
+
"classification": ["PHI"],
|
|
1570
|
+
"mask": "hash",
|
|
1571
|
+
"description": "Patient member ID"
|
|
1572
|
+
},
|
|
1573
|
+
{
|
|
1574
|
+
"name": "provider_npi",
|
|
1575
|
+
"type": "string",
|
|
1576
|
+
"description": "Healthcare provider NPI number"
|
|
1577
|
+
},
|
|
1578
|
+
{
|
|
1579
|
+
"name": "service_date",
|
|
1580
|
+
"type": "datetime",
|
|
1581
|
+
"description": "Date of medical service"
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
"name": "diagnosis_code",
|
|
1585
|
+
"type": "string",
|
|
1586
|
+
"description": "Primary diagnosis ICD-10 code"
|
|
1587
|
+
},
|
|
1588
|
+
{
|
|
1589
|
+
"name": "procedure_code",
|
|
1590
|
+
"type": "string",
|
|
1591
|
+
"description": "Medical procedure CPT code"
|
|
1592
|
+
},
|
|
1593
|
+
{
|
|
1594
|
+
"name": "billed_amount",
|
|
1595
|
+
"type": "number",
|
|
1596
|
+
"description": "Amount billed by provider"
|
|
1597
|
+
},
|
|
1598
|
+
{
|
|
1599
|
+
"name": "allowed_amount",
|
|
1600
|
+
"type": "number",
|
|
1601
|
+
"description": "Amount allowed by insurance"
|
|
1602
|
+
}
|
|
1603
|
+
],
|
|
1604
|
+
"_version": 1
|
|
1605
|
+
}
|
|
1606
|
+
```
|
|
1607
|
+
|
|
1608
|
+
### **Multi-File JSON Producer**
|
|
1609
|
+
|
|
1610
|
+
```json
|
|
1611
|
+
{
|
|
1612
|
+
"$schema": "https://raw.githubusercontent.com/ForzaLabs/remora-public/refs/heads/main/json_schemas/producer-schema.json",
|
|
1613
|
+
"name": "application-logs",
|
|
1614
|
+
"description": "Application event logs from multiple files",
|
|
1615
|
+
"source": "s3-logs",
|
|
1616
|
+
"settings": {
|
|
1617
|
+
"fileKey": "logs/app-%.jsonl",
|
|
1618
|
+
"fileType": "JSONL"
|
|
1619
|
+
},
|
|
1620
|
+
"dimensions": [
|
|
1621
|
+
{
|
|
1622
|
+
"name": "timestamp",
|
|
1623
|
+
"type": "datetime",
|
|
1624
|
+
"description": "Event timestamp"
|
|
1625
|
+
},
|
|
1626
|
+
{
|
|
1627
|
+
"name": "level",
|
|
1628
|
+
"type": "string",
|
|
1629
|
+
"description": "Log level (INFO, WARN, ERROR)"
|
|
1630
|
+
},
|
|
1631
|
+
{
|
|
1632
|
+
"name": "user_id",
|
|
1633
|
+
"type": "string",
|
|
1634
|
+
"classification": ["PII"],
|
|
1635
|
+
"mask": "hash",
|
|
1636
|
+
"description": "User performing the action"
|
|
1637
|
+
},
|
|
1638
|
+
{
|
|
1639
|
+
"name": "action",
|
|
1640
|
+
"type": "string",
|
|
1641
|
+
"description": "Action performed"
|
|
1642
|
+
},
|
|
1643
|
+
{
|
|
1644
|
+
"name": "ip_address",
|
|
1645
|
+
"type": "string",
|
|
1646
|
+
"classification": ["PII"],
|
|
1647
|
+
"mask": "mask",
|
|
1648
|
+
"description": "User IP address"
|
|
1649
|
+
},
|
|
1650
|
+
{
|
|
1651
|
+
"name": "response_time_ms",
|
|
1652
|
+
"type": "number",
|
|
1653
|
+
"description": "Response time in milliseconds"
|
|
1654
|
+
}
|
|
1655
|
+
],
|
|
1656
|
+
"_version": 1
|
|
1657
|
+
}
|
|
1658
|
+
```
|