@smart-data-engines/sde 0.1.0-dev.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/LICENSE +201 -0
- package/NOTICE +13 -0
- package/README.md +153 -0
- package/bin/weather.mjs +32 -0
- package/dist/_usage.d.ts +30 -0
- package/dist/_usage.js +194 -0
- package/dist/_usage.js.map +1 -0
- package/dist/bulk.d.ts +9 -0
- package/dist/bulk.js +83 -0
- package/dist/bulk.js.map +1 -0
- package/dist/canonical.d.ts +46 -0
- package/dist/canonical.js +150 -0
- package/dist/canonical.js.map +1 -0
- package/dist/capabilities.d.ts +48 -0
- package/dist/capabilities.js +62 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/cutover.d.ts +36 -0
- package/dist/cutover.js +219 -0
- package/dist/cutover.js.map +1 -0
- package/dist/demo/model.d.ts +28 -0
- package/dist/demo/model.js +40 -0
- package/dist/demo/model.js.map +1 -0
- package/dist/demo/project.d.ts +19 -0
- package/dist/demo/project.js +128 -0
- package/dist/demo/project.js.map +1 -0
- package/dist/demo/weather.d.ts +73 -0
- package/dist/demo/weather.js +334 -0
- package/dist/demo/weather.js.map +1 -0
- package/dist/engines/_clickhouse-connection.d.ts +17 -0
- package/dist/engines/_clickhouse-connection.js +182 -0
- package/dist/engines/_clickhouse-connection.js.map +1 -0
- package/dist/engines/_tls-peer-identity.d.ts +2 -0
- package/dist/engines/_tls-peer-identity.js +23 -0
- package/dist/engines/_tls-peer-identity.js.map +1 -0
- package/dist/engines/_write-fences.d.ts +51 -0
- package/dist/engines/_write-fences.js +189 -0
- package/dist/engines/_write-fences.js.map +1 -0
- package/dist/engines/clickhouse.d.ts +193 -0
- package/dist/engines/clickhouse.js +899 -0
- package/dist/engines/clickhouse.js.map +1 -0
- package/dist/engines/postgres.d.ts +293 -0
- package/dist/engines/postgres.js +981 -0
- package/dist/engines/postgres.js.map +1 -0
- package/dist/errors.d.ts +89 -0
- package/dist/errors.js +90 -0
- package/dist/errors.js.map +1 -0
- package/dist/frozen-verification.d.ts +26 -0
- package/dist/frozen-verification.js +67 -0
- package/dist/frozen-verification.js.map +1 -0
- package/dist/generation.d.ts +34 -0
- package/dist/generation.js +81 -0
- package/dist/generation.js.map +1 -0
- package/dist/groups.d.ts +17 -0
- package/dist/groups.js +66 -0
- package/dist/groups.js.map +1 -0
- package/dist/hashing.d.ts +68 -0
- package/dist/hashing.js +146 -0
- package/dist/hashing.js.map +1 -0
- package/dist/in-place-index.d.ts +43 -0
- package/dist/in-place-index.js +272 -0
- package/dist/in-place-index.js.map +1 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/inspection.d.ts +19 -0
- package/dist/inspection.js +31 -0
- package/dist/inspection.js.map +1 -0
- package/dist/internal.d.ts +42 -0
- package/dist/internal.js +56 -0
- package/dist/internal.js.map +1 -0
- package/dist/layout.d.ts +36 -0
- package/dist/layout.js +62 -0
- package/dist/layout.js.map +1 -0
- package/dist/migration.d.ts +197 -0
- package/dist/migration.js +592 -0
- package/dist/migration.js.map +1 -0
- package/dist/model.d.ts +93 -0
- package/dist/model.js +313 -0
- package/dist/model.js.map +1 -0
- package/dist/physical.d.ts +128 -0
- package/dist/physical.js +421 -0
- package/dist/physical.js.map +1 -0
- package/dist/placement.d.ts +157 -0
- package/dist/placement.js +651 -0
- package/dist/placement.js.map +1 -0
- package/dist/provisioning.d.ts +6 -0
- package/dist/provisioning.js +45 -0
- package/dist/provisioning.js.map +1 -0
- package/dist/query.d.ts +68 -0
- package/dist/query.js +340 -0
- package/dist/query.js.map +1 -0
- package/dist/routing.d.ts +25 -0
- package/dist/routing.js +35 -0
- package/dist/routing.js.map +1 -0
- package/dist/schema.d.ts +110 -0
- package/dist/schema.js +337 -0
- package/dist/schema.js.map +1 -0
- package/dist/session.d.ts +195 -0
- package/dist/session.js +870 -0
- package/dist/session.js.map +1 -0
- package/dist/shapes.d.ts +30 -0
- package/dist/shapes.js +112 -0
- package/dist/shapes.js.map +1 -0
- package/dist/staging.d.ts +29 -0
- package/dist/staging.js +214 -0
- package/dist/staging.js.map +1 -0
- package/dist/telemetry.d.ts +468 -0
- package/dist/telemetry.js +872 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/testing/loader.d.ts +38 -0
- package/dist/testing/loader.js +86 -0
- package/dist/testing/loader.js.map +1 -0
- package/dist/testing/memory.d.ts +131 -0
- package/dist/testing/memory.js +311 -0
- package/dist/testing/memory.js.map +1 -0
- package/dist/timestamp.d.ts +20 -0
- package/dist/timestamp.js +89 -0
- package/dist/timestamp.js.map +1 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.js +100 -0
- package/dist/types.js.map +1 -0
- package/dist/verification.d.ts +41 -0
- package/dist/verification.js +169 -0
- package/dist/verification.js.map +1 -0
- package/dist/watermark.d.ts +103 -0
- package/dist/watermark.js +170 -0
- package/dist/watermark.js.map +1 -0
- package/dist/write-fence.d.ts +58 -0
- package/dist/write-fence.js +225 -0
- package/dist/write-fence.js.map +1 -0
- package/package.json +86 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The placement map: where every group lives, and where every operation goes.
|
|
3
|
+
*
|
|
4
|
+
* The only instruction this library takes from outside, and therefore the one input that refuses
|
|
5
|
+
* rather than degrades. A bad signature, a mismatched model version, an unknown contract version:
|
|
6
|
+
* all of them stop the library from starting, because the map decides where data is written.
|
|
7
|
+
*
|
|
8
|
+
* An unsigned map is valid - that is the no-account mode, and it is a supported way to use this
|
|
9
|
+
* library rather than a gap. What is refused is a map carrying a signature, and therefore claiming to
|
|
10
|
+
* come from us, when there is no key to check the claim against.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
|
|
13
|
+
import { CanonicalError, canonicalBytes, compareCodePoints } from './canonical.js';
|
|
14
|
+
import { MapError } from './errors.js';
|
|
15
|
+
import { DRAIN_TABLE, EPOCH_COLUMN } from './generation.js';
|
|
16
|
+
import { colocationGroups } from './groups.js';
|
|
17
|
+
import { CONTRACT, entityOf } from './model.js';
|
|
18
|
+
import { PHYSICAL_DESIGN_SINCE, checkAgainstModel, parseIndexes, parseKeyOrder, parsePartitionBy, } from './physical.js';
|
|
19
|
+
import { enumerateShapes, shapeId } from './shapes.js';
|
|
20
|
+
export const MAP_CONTRACT = 5;
|
|
21
|
+
/**
|
|
22
|
+
* The placement map's format version, which is not the IR's - see `CONTRACT`.
|
|
23
|
+
*
|
|
24
|
+
* Five since 23 September 2026, for the physical design vocabulary (`physical.ts`): the order of a
|
|
25
|
+
* key, a time partition, and index methods. An earlier library would ignore `"method": "brin"` and
|
|
26
|
+
* build a B-tree from the same document, which is a loosening and therefore a bump. A contract-5
|
|
27
|
+
* library reads contracts 1 to 4 with their meaning unchanged, and refuses the new keys in them.
|
|
28
|
+
*
|
|
29
|
+
* Two because the map gained `also_write`, which a contract-1 library would ignore while a
|
|
30
|
+
* contract-2 one honours it: the same document, two different sets of engines written to, and the
|
|
31
|
+
* difference decided by which version happens to be installed. A loosening bumps the number.
|
|
32
|
+
*/
|
|
33
|
+
export const ALSO_WRITE_SINCE = 2;
|
|
34
|
+
/**
|
|
35
|
+
* The contract that introduced `also_write`.
|
|
36
|
+
*
|
|
37
|
+
* A document declaring an earlier contract and carrying the key is refused. That is a *tightening*
|
|
38
|
+
* - no bump - and it exists because of who makes the mistake: a producer that grew the key and
|
|
39
|
+
* forgot to raise the number, which is what happened on the control-plane side of the very change
|
|
40
|
+
* that added it.
|
|
41
|
+
*/
|
|
42
|
+
export const MAP_CONTRACT_FLOOR = 1;
|
|
43
|
+
/**
|
|
44
|
+
* The oldest map format this library still reads.
|
|
45
|
+
*
|
|
46
|
+
* Backwards compatible, forwards strict, and the asymmetry is knowledge rather than kindness: every
|
|
47
|
+
* contract-1 document is a valid contract-2 one with a key absent, which reads as "no dual write"
|
|
48
|
+
* and is a complete meaning. What came after this library cannot be known, so a higher number is
|
|
49
|
+
* refused rather than interpreted.
|
|
50
|
+
*/
|
|
51
|
+
export const WATERMARK_TABLE = 'sde_map_state';
|
|
52
|
+
/**
|
|
53
|
+
* The table the Python library keeps its map bookkeeping in: the highest map version applied
|
|
54
|
+
* against an engine, which is what stops an older signed map being loaded over a newer one.
|
|
55
|
+
*
|
|
56
|
+
* This runtime has no engine adapters, so it never writes that table - and it refuses a layout
|
|
57
|
+
* naming it anyway. The rule is a *parsing* rule, and a parsing rule that holds in one language
|
|
58
|
+
* and not the other is exactly the divergence the conformance suite exists to catch: one map, two
|
|
59
|
+
* libraries, accepted here and refused there. Nothing in this file could have discovered that on
|
|
60
|
+
* its own.
|
|
61
|
+
*/
|
|
62
|
+
export const BACKFILL_TABLE = 'sde_backfill_state';
|
|
63
|
+
/**
|
|
64
|
+
* The table the Python library keeps its backfill progress in: how many rows of each entity a
|
|
65
|
+
* migration has copied into an engine, which is what lets an interrupted backfill resume.
|
|
66
|
+
*
|
|
67
|
+
* The second reserved name, and the reason the refusal below is written over a table of them
|
|
68
|
+
* rather than over one string.
|
|
69
|
+
*/
|
|
70
|
+
export const RESERVED_TABLES = [
|
|
71
|
+
[
|
|
72
|
+
WATERMARK_TABLE,
|
|
73
|
+
'the highest map version applied against an engine, which is what stops an older map from ' +
|
|
74
|
+
'being loaded over a newer one',
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
BACKFILL_TABLE,
|
|
78
|
+
'how many rows of each entity a migration has copied into an engine, which is what lets an ' +
|
|
79
|
+
'interrupted backfill resume instead of starting over',
|
|
80
|
+
],
|
|
81
|
+
];
|
|
82
|
+
/**
|
|
83
|
+
* Table names the library owns, and what each one holds - kept in the same order as the Python
|
|
84
|
+
* mapping so that a document colliding with two of them is refused for the same one in both.
|
|
85
|
+
*/
|
|
86
|
+
const AUTO = Symbol('auto-layout');
|
|
87
|
+
function asRecord(value, where) {
|
|
88
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
89
|
+
throw new MapError(`${where}: expected an object`);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
function readLayout(raw, where, contract) {
|
|
94
|
+
const body = asRecord(raw, `${where}: layout`);
|
|
95
|
+
if (body['auto'] === true) {
|
|
96
|
+
if (Object.keys(body).length !== 1) {
|
|
97
|
+
throw new MapError(`${where}: a layout is either auto or explicit, not both. Two sources of truth for a schema ` +
|
|
98
|
+
'is how a column ends up existing in one place and not the other.');
|
|
99
|
+
}
|
|
100
|
+
return AUTO;
|
|
101
|
+
}
|
|
102
|
+
const tables = body['tables'];
|
|
103
|
+
if (typeof tables !== 'object' || tables === null || Object.keys(tables).length === 0) {
|
|
104
|
+
throw new MapError(`${where}: layout needs a non-empty 'tables' mapping entity to table name, or {"auto": true} ` +
|
|
105
|
+
'to have one derived from the model');
|
|
106
|
+
}
|
|
107
|
+
for (const [name, holds] of RESERVED_TABLES) {
|
|
108
|
+
const reserved = Object.entries(tables)
|
|
109
|
+
.filter(([, table]) => table === name)
|
|
110
|
+
.map(([entity]) => entity)
|
|
111
|
+
.sort();
|
|
112
|
+
if (reserved.length > 0) {
|
|
113
|
+
throw new MapError(`${where}: ${JSON.stringify(reserved)} would be stored in a table called ` +
|
|
114
|
+
`'${name}', which this library keeps its own bookkeeping in - ${holds}. A client table ` +
|
|
115
|
+
'under that name would be read as bookkeeping and written to as bookkeeping. Rename ' +
|
|
116
|
+
'the table; the name is yours to choose everywhere else.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const columns = (body['columns'] ?? {});
|
|
120
|
+
let keyOrder = {};
|
|
121
|
+
let partitionBy = {};
|
|
122
|
+
if (contract < PHYSICAL_DESIGN_SINCE) {
|
|
123
|
+
// Before contract 5 `partition_by` had no meaning, and the refusal is the whole point: the key
|
|
124
|
+
// was parsed, emitted by the control plane when non-empty, and no renderer ever applied it. A
|
|
125
|
+
// contract-4 document may not start meaning something by it now (`errors/037`).
|
|
126
|
+
if (Object.keys((body['partition_by'] ?? {})).length > 0) {
|
|
127
|
+
throw new MapError(`${where}: this layout declares partition_by in a document declaring map contract ` +
|
|
128
|
+
`${contract}, where no library renders it - the table would be created unpartitioned ` +
|
|
129
|
+
`and nothing would say so. Partitioning is map contract ${PHYSICAL_DESIGN_SINCE}; ` +
|
|
130
|
+
'remove the key or raise the contract.');
|
|
131
|
+
}
|
|
132
|
+
if ('key_order' in body) {
|
|
133
|
+
throw new MapError(`${where}: key_order is map contract ${PHYSICAL_DESIGN_SINCE} and this document declares ` +
|
|
134
|
+
`${contract}. A library of that contract would ignore it and order the key as declared, ` +
|
|
135
|
+
'so one document would build two different tables.');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const named = tables;
|
|
140
|
+
// Absent is "no design"; `null` is a key with a value that is not a design, refused as the
|
|
141
|
+
// reference refuses it. `?? {}` would read the two as one - the `or` against `??` divergence
|
|
142
|
+
// this suite has caught before, one key over.
|
|
143
|
+
keyOrder = parseKeyOrder(body['key_order'] === undefined ? {} : body['key_order'], where, named);
|
|
144
|
+
partitionBy = parsePartitionBy(body['partition_by'] === undefined ? {} : body['partition_by'], where, named);
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
tables: tables,
|
|
148
|
+
columns,
|
|
149
|
+
indexes: parseIndexes(body['indexes'], where, tables, columns, contract),
|
|
150
|
+
partitionBy,
|
|
151
|
+
keyOrder,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The derived copies writes are additionally sent to. Four refusals, each with its failure.
|
|
156
|
+
*
|
|
157
|
+
* Validated when the map loads rather than at the first write, for the reason the routing table
|
|
158
|
+
* was: a map is handed over once and obeyed for months, so a defect in it should be found when it
|
|
159
|
+
* arrives and not by the request that happens to touch it. During a migration that request is a
|
|
160
|
+
* write, and the failure is a write that goes to one engine when the map says two.
|
|
161
|
+
*/
|
|
162
|
+
function readAlsoWrite(raw, where, contract, source, derived) {
|
|
163
|
+
if (raw === undefined || raw === null)
|
|
164
|
+
return [];
|
|
165
|
+
if (contract < ALSO_WRITE_SINCE) {
|
|
166
|
+
throw new MapError(`${where}: this map declares format contract ${contract} and uses 'also_write', which ` +
|
|
167
|
+
`contract ${ALSO_WRITE_SINCE} introduced. Refused rather than honoured, and the reason is ` +
|
|
168
|
+
'who makes this mistake: a producer that grew the key and forgot to raise the number. ' +
|
|
169
|
+
'Honouring it would mean a contract-1 library ignoring the fan-out while a contract-2 one ' +
|
|
170
|
+
'performs it - the same document, two sets of engines written to - which is the whole ' +
|
|
171
|
+
'failure the version exists to prevent.');
|
|
172
|
+
}
|
|
173
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
174
|
+
throw new MapError(`${where}: 'also_write' is a non-empty list of materialisation ids, or absent. Absent means ` +
|
|
175
|
+
'writes go to the source alone; an empty list would be a claim that fan-out was ' +
|
|
176
|
+
'considered and none chosen, which is a stronger thing to say and not what the planner ' +
|
|
177
|
+
'means when it omits the key.');
|
|
178
|
+
}
|
|
179
|
+
const byId = new Map(derived.map((m) => [m.id, m]));
|
|
180
|
+
const out = [];
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
for (const entry of raw) {
|
|
183
|
+
if (typeof entry !== 'string') {
|
|
184
|
+
throw new MapError(`${where}: 'also_write' holds materialisation ids, not ${JSON.stringify(entry)}`);
|
|
185
|
+
}
|
|
186
|
+
if (entry === source.id) {
|
|
187
|
+
throw new MapError(`${where}: 'also_write' names the source '${entry}'. The source is where writes already ` +
|
|
188
|
+
'land - listing it would either write the row twice or read as though the source were ' +
|
|
189
|
+
'somehow optional, and both are worse than the refusal.');
|
|
190
|
+
}
|
|
191
|
+
if (seen.has(entry)) {
|
|
192
|
+
throw new MapError(`${where}: 'also_write' names '${entry}' twice. Two identical fan-out targets is either a ` +
|
|
193
|
+
'duplicated row or a document nobody meant to write.');
|
|
194
|
+
}
|
|
195
|
+
const found = byId.get(entry);
|
|
196
|
+
if (found === undefined) {
|
|
197
|
+
throw new MapError(`${where}: 'also_write' names '${entry}', which is not a derived materialisation of this ` +
|
|
198
|
+
`group. It has ${JSON.stringify([...byId.keys()].sort())}. A fan-out target that does ` +
|
|
199
|
+
'not exist is a write with nowhere to go, and during a migration that is a row the copy ' +
|
|
200
|
+
'never receives.');
|
|
201
|
+
}
|
|
202
|
+
seen.add(entry);
|
|
203
|
+
out.push(found);
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
function readMaterialization(raw, where, isSource, contract) {
|
|
208
|
+
const body = asRecord(raw, where);
|
|
209
|
+
for (const required of ['id', 'engine', 'layout']) {
|
|
210
|
+
if (!(required in body))
|
|
211
|
+
throw new MapError(`${where}: materialisation is missing '${required}'`);
|
|
212
|
+
}
|
|
213
|
+
const lag = body['lag_budget_ms'];
|
|
214
|
+
if (isSource && lag !== undefined && lag !== null) {
|
|
215
|
+
throw new MapError(`${where}: the source materialisation cannot have a lag budget. The source is where writes ` +
|
|
216
|
+
'land, so it is by definition not behind anything.');
|
|
217
|
+
}
|
|
218
|
+
if (!isSource && (lag === undefined || lag === null)) {
|
|
219
|
+
throw new MapError(`${where}: a derived materialisation needs lag_budget_ms. Without it nobody - not the client, ` +
|
|
220
|
+
'not the monitoring - can tell whether it is healthy or hours behind.');
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
mat: {
|
|
224
|
+
id: String(body['id']),
|
|
225
|
+
engine: String(body['engine']),
|
|
226
|
+
lagBudgetMs: isSource ? null : Number(lag),
|
|
227
|
+
},
|
|
228
|
+
layout: readLayout(body['layout'], where, contract),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Derive the obvious schema for a group.
|
|
233
|
+
*
|
|
234
|
+
* Tier 0 does not create schema, so this only has to produce the same *names* the Python
|
|
235
|
+
* implementation does - which is what a routing decision depends on. Column types are a Tier 2
|
|
236
|
+
* concern and are deliberately left empty rather than guessed at half-right.
|
|
237
|
+
*/
|
|
238
|
+
function defaultLayout(model, members) {
|
|
239
|
+
const tables = {};
|
|
240
|
+
for (const member of members) {
|
|
241
|
+
entityOf(model, member);
|
|
242
|
+
tables[member] = member
|
|
243
|
+
.normalize('NFC')
|
|
244
|
+
.replace(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/g, '_')
|
|
245
|
+
.toLowerCase();
|
|
246
|
+
}
|
|
247
|
+
return { tables, columns: {}, indexes: [], partitionBy: {}, keyOrder: {} };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Normalise what the caller supplied into named keys, refusing what cannot be a key.
|
|
251
|
+
*
|
|
252
|
+
* A key of the wrong length is refused here rather than left to fail verification: it is the
|
|
253
|
+
* client's configuration, and a rotation that silently did not take effect - because the new key
|
|
254
|
+
* was pasted a byte short - is the failure this whole mechanism exists to avoid.
|
|
255
|
+
*/
|
|
256
|
+
function keySet(publicKey) {
|
|
257
|
+
let pairs;
|
|
258
|
+
if (publicKey instanceof Uint8Array) {
|
|
259
|
+
pairs = [['', publicKey]];
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
pairs = Object.entries(publicKey).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
263
|
+
if (pairs.length === 0) {
|
|
264
|
+
throw new MapError('an empty set of public keys was supplied. That is not the no-account mode - a map with ' +
|
|
265
|
+
'no signature is - it is a configuration that can verify nothing. Pass the keys, or ' +
|
|
266
|
+
'load an unsigned map.');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const [name, value] of pairs) {
|
|
270
|
+
if (value.length !== 32) {
|
|
271
|
+
throw new MapError(`the public key ${JSON.stringify(name || '(unnamed)')} is ${value.length} bytes and an ` +
|
|
272
|
+
'Ed25519 public key is 32. Refused here rather than at verification: a key pasted a ' +
|
|
273
|
+
'byte short would look like a map that does not verify, and the two have completely ' +
|
|
274
|
+
'different fixes.');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return pairs;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Verify, and report **which** of the caller's keys did it.
|
|
281
|
+
*
|
|
282
|
+
* **Every key is tried, and `key_id` only decides the order.** The signature block is excluded
|
|
283
|
+
* from the signed payload in its entirety, so `key_id` is not covered by the signature and anybody
|
|
284
|
+
* can edit it. Treating it as authoritative would be trusting an attacker's annotation; making it
|
|
285
|
+
* authenticated would change what the payload is, which recomputes every signature ever issued and
|
|
286
|
+
* is a contract bump in every language at once. As an ordering it costs nothing to be wrong about.
|
|
287
|
+
*
|
|
288
|
+
* The cost of accepting a set is real: **an old key verifies for as long as the client keeps it.**
|
|
289
|
+
* This library has no clock, so it cannot expire one - and that same absence is what makes "you
|
|
290
|
+
* stop paying and nothing breaks" true. Revocation is the client removing a key from their own
|
|
291
|
+
* configuration, which is why the id that verified is reported.
|
|
292
|
+
*/
|
|
293
|
+
/** Internal shared verifier for signed instruction envelopes; not a package entry point. */
|
|
294
|
+
export function verifyMapSignature(raw, publicKey) {
|
|
295
|
+
const signature = asRecord(raw['signature'], 'signature');
|
|
296
|
+
if (signature['alg'] !== 'ed25519') {
|
|
297
|
+
throw new MapError('only ed25519 signatures are understood');
|
|
298
|
+
}
|
|
299
|
+
const value = Buffer.from(String(signature['value']), 'base64');
|
|
300
|
+
const { signature: _omit, ...rest } = raw;
|
|
301
|
+
const payload = canonicalBytes(rest);
|
|
302
|
+
const claimed = typeof signature['key_id'] === 'string' ? signature['key_id'] : null;
|
|
303
|
+
const keys = keySet(publicKey);
|
|
304
|
+
const ordered = claimed === null
|
|
305
|
+
? keys
|
|
306
|
+
: [...keys.filter(([name]) => name === claimed), ...keys.filter(([name]) => name !== claimed)];
|
|
307
|
+
for (const [name, raw32] of ordered) {
|
|
308
|
+
// Node wants a KeyObject; a raw 32-byte Ed25519 key becomes one via a minimal DER wrapper. The
|
|
309
|
+
// alternative is asking callers for PEM, which is a worse interface for a key that is 32 bytes.
|
|
310
|
+
const der = Buffer.concat([
|
|
311
|
+
Buffer.from('302a300506032b6570032100', 'hex'),
|
|
312
|
+
Buffer.from(raw32),
|
|
313
|
+
]);
|
|
314
|
+
const key = createPublicKey({ key: der, format: 'der', type: 'spki' });
|
|
315
|
+
if (verifySignature(null, payload, key, value)) {
|
|
316
|
+
return name === '' ? null : name;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const tried = ordered.map(([name]) => name || '(unnamed)');
|
|
320
|
+
throw new MapError(`the map's signature does not verify against any of the ${ordered.length} key(s) supplied ` +
|
|
321
|
+
`(${JSON.stringify(tried)}); the map says it was signed with ${JSON.stringify(claimed)}. ` +
|
|
322
|
+
'This is refused rather than warned about: the map decides where your data is written. If a ' +
|
|
323
|
+
'rotation is in progress, the key named above is the one to add - and while both are ' +
|
|
324
|
+
'configured, maps signed with either are accepted.');
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Every routing entry must name a materialisation that exists, in the shape's own group.
|
|
328
|
+
*
|
|
329
|
+
* This lived in `materializationById` - that is, at the first read that routed through the broken
|
|
330
|
+
* entry. Deferring it there turns a mistake in a document we hand over into an error inside the
|
|
331
|
+
* client's request path at a moment nobody can predict: only the shapes routing through that entry
|
|
332
|
+
* fail, so a run that never issues those operations is green and the map looks applied.
|
|
333
|
+
*
|
|
334
|
+
* Two levels, because the model is optional. Without one: the target must be an id declared
|
|
335
|
+
* somewhere in this map. With one: it must be declared in the group the shape belongs to - the
|
|
336
|
+
* check that matters, since ids are unique only *within* a group.
|
|
337
|
+
*/
|
|
338
|
+
function checkRoutingTargets(routing, groups, model) {
|
|
339
|
+
const declared = Object.create(null);
|
|
340
|
+
const everywhere = new Set();
|
|
341
|
+
for (const [name, placement] of Object.entries(groups)) {
|
|
342
|
+
const ids = new Set([placement.source, ...placement.derived].map((m) => m.id));
|
|
343
|
+
declared[name] = ids;
|
|
344
|
+
for (const id of ids)
|
|
345
|
+
everywhere.add(id);
|
|
346
|
+
}
|
|
347
|
+
const entries = Object.entries(routing).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
348
|
+
for (const [shapeIdent, target] of entries) {
|
|
349
|
+
if (typeof target !== 'string') {
|
|
350
|
+
throw new MapError(`the routing entry for shape '${shapeIdent}' is not a materialisation id. Routing maps a ` +
|
|
351
|
+
'shape id to one id, and anything else is a table nobody can look up.');
|
|
352
|
+
}
|
|
353
|
+
if (!everywhere.has(target)) {
|
|
354
|
+
throw new MapError(`the routing table sends shape '${shapeIdent}' to materialisation '${target}', and no ` +
|
|
355
|
+
'group in this map declares one with that id. The map is internally inconsistent: ' +
|
|
356
|
+
`declared ids are ${JSON.stringify([...everywhere].sort())}.`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (!model)
|
|
360
|
+
return;
|
|
361
|
+
const shapes = new Map(enumerateShapes(model).map((shape) => [shapeId(shape), shape]));
|
|
362
|
+
for (const [shapeIdent, target] of entries) {
|
|
363
|
+
const shape = shapes.get(shapeIdent);
|
|
364
|
+
if (!shape) {
|
|
365
|
+
throw new MapError(`the routing table has an entry for shape '${shapeIdent}', and this model does not produce ` +
|
|
366
|
+
'that shape. The model version matched, so the two sides enumerated shapes differently - ' +
|
|
367
|
+
"which is the divergence that puts one library's write in a table another library never " +
|
|
368
|
+
'looks at.');
|
|
369
|
+
}
|
|
370
|
+
if (!declared[shape.group]?.has(target)) {
|
|
371
|
+
throw new MapError(`the routing table sends shape '${shapeIdent}' - which belongs to group '${shape.group}' - ` +
|
|
372
|
+
`to materialisation '${target}', which that group does not declare. ` +
|
|
373
|
+
'Materialisation ids are unique only within a group, so this would read ' +
|
|
374
|
+
`${shape.entity} out of a copy that does not hold it.`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The physical design rules that need the model: key permutations and partitions on the key.
|
|
380
|
+
*
|
|
381
|
+
* Materialisations in document order within the group, entities in name order within a layout -
|
|
382
|
+
* the ordering the reference gives it, so one document refuses the same way in both languages.
|
|
383
|
+
*/
|
|
384
|
+
function checkPhysical(placement, model) {
|
|
385
|
+
for (const material of [placement.source, ...placement.derived]) {
|
|
386
|
+
const layout = material.layout;
|
|
387
|
+
const where = `group '${placement.group}': '${material.id}'`;
|
|
388
|
+
const keyOrder = layout.keyOrder ?? {};
|
|
389
|
+
const entities = [...new Set([...Object.keys(keyOrder), ...Object.keys(layout.partitionBy)])].sort(compareCodePoints);
|
|
390
|
+
for (const entity of entities) {
|
|
391
|
+
const spec = model.entities.find((candidate) => candidate.name === entity);
|
|
392
|
+
if (spec === undefined) {
|
|
393
|
+
throw new MapError(`${where}: the physical design names '${entity}', which this model does not declare, so ` +
|
|
394
|
+
'there is no key to order or partition by.');
|
|
395
|
+
}
|
|
396
|
+
const fieldTypes = Object.create(null);
|
|
397
|
+
for (const field of spec.fields)
|
|
398
|
+
fieldTypes[field.name] = field.type;
|
|
399
|
+
checkAgainstModel(where, entity, spec.key, fieldTypes, Object.hasOwn(keyOrder, entity) ? keyOrder[entity] : undefined, Object.hasOwn(layout.partitionBy, entity) ? layout.partitionBy[entity] : undefined);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function requireCanonicalText(value) {
|
|
404
|
+
if (typeof value === 'string') {
|
|
405
|
+
// With /u, a valid surrogate pair is one astral code point; only unpaired halves match.
|
|
406
|
+
if (value !== value.normalize('NFC') || /[\uD800-\uDFFF]/u.test(value)) {
|
|
407
|
+
throw new MapError('placement map payload strings and member names must be Unicode scalar text in NFC');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else if (Array.isArray(value)) {
|
|
411
|
+
for (const child of value)
|
|
412
|
+
requireCanonicalText(child);
|
|
413
|
+
}
|
|
414
|
+
else if (value !== null && typeof value === 'object') {
|
|
415
|
+
for (const [key, child] of Object.entries(value)) {
|
|
416
|
+
requireCanonicalText(key);
|
|
417
|
+
requireCanonicalText(child);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
export function loadMap(raw, options = {}) {
|
|
422
|
+
const body = asRecord(structuredClone(raw), 'a placement map');
|
|
423
|
+
const contract = body['contract'];
|
|
424
|
+
if (typeof contract !== 'number' || !Number.isInteger(contract)) {
|
|
425
|
+
throw new MapError(`this map declares format contract ${JSON.stringify(contract)}, which is not a version ` +
|
|
426
|
+
`number. This library reads ${MAP_CONTRACT_FLOOR} to ${MAP_CONTRACT}.`);
|
|
427
|
+
}
|
|
428
|
+
if (contract < MAP_CONTRACT_FLOOR) {
|
|
429
|
+
throw new MapError(`this map declares format contract ${contract} and the oldest this library still reads is ` +
|
|
430
|
+
`${MAP_CONTRACT_FLOOR}.`);
|
|
431
|
+
}
|
|
432
|
+
if (contract > MAP_CONTRACT) {
|
|
433
|
+
throw new MapError(`this map declares format contract ${contract} and this library implements ` +
|
|
434
|
+
`${MAP_CONTRACT}. Refusing rather than guessing: a version this library does not know may ` +
|
|
435
|
+
'say something with a key it has never heard of, and the difference between two contract ' +
|
|
436
|
+
'versions is exactly the kind of thing that would otherwise be interpreted as a missing ' +
|
|
437
|
+
'field meaning zero. Upgrade the library.');
|
|
438
|
+
}
|
|
439
|
+
// Reject instead of rewriting physical names after verifying a normalized signature.
|
|
440
|
+
// key_id is outside the payload and remains only a hint about which trusted key to try first.
|
|
441
|
+
requireCanonicalText(Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'signature')));
|
|
442
|
+
const projectId = contract >= 4 ? body['project_id'] : undefined;
|
|
443
|
+
if (contract >= 4 && (typeof projectId !== 'string' || !/^[0-9a-f]{32}$/.test(projectId))) {
|
|
444
|
+
throw new MapError('contract 4 requires a 32-digit lowercase hexadecimal project_id');
|
|
445
|
+
}
|
|
446
|
+
if (contract < 4 && 'project_id' in body)
|
|
447
|
+
throw new MapError('project_id requires placement map contract 4');
|
|
448
|
+
const modelVersion = body['model_version'];
|
|
449
|
+
if (typeof modelVersion !== 'string' || modelVersion.length === 0) {
|
|
450
|
+
throw new MapError('the map does not say which model version it is for');
|
|
451
|
+
}
|
|
452
|
+
if (options.model && options.model.version !== modelVersion) {
|
|
453
|
+
throw new MapError(`this map is for model version ${modelVersion} and your declared model is ` +
|
|
454
|
+
`${options.model.version}. Something changed in your entities; ask for a new map rather than ` +
|
|
455
|
+
'running against this one, because the difference cannot be guessed.');
|
|
456
|
+
}
|
|
457
|
+
const mapVersion = body['map_version'];
|
|
458
|
+
if (typeof mapVersion !== 'number' || !Number.isInteger(mapVersion) || mapVersion < 1) {
|
|
459
|
+
throw new MapError(`this map declares map_version ${JSON.stringify(mapVersion)}, which is not a version ` +
|
|
460
|
+
"number. It used to be read as Number(body['map_version'] ?? 0), so a document without " +
|
|
461
|
+
'one loaded as version 0 - and this is the number that decides whether an older map is ' +
|
|
462
|
+
"being replayed over a newer one against the client's own engines.");
|
|
463
|
+
}
|
|
464
|
+
let verifiedWith = null;
|
|
465
|
+
const signaturePresent = body['signature'] !== undefined && body['signature'] !== null;
|
|
466
|
+
if (options.requireSignature === true && !signaturePresent) {
|
|
467
|
+
throw new MapError('a signature was required and this map has none');
|
|
468
|
+
}
|
|
469
|
+
if (signaturePresent) {
|
|
470
|
+
if (!options.publicKey) {
|
|
471
|
+
throw new MapError('this map is signed, which is a claim that it came from us, and no public key was provided ' +
|
|
472
|
+
'to check that claim. Either pass the key, or use an unsigned map - an unsigned map is a ' +
|
|
473
|
+
'supported mode, an unverifiable claim is not.');
|
|
474
|
+
}
|
|
475
|
+
verifiedWith = verifyMapSignature(body, options.publicKey);
|
|
476
|
+
}
|
|
477
|
+
const groupsRaw = body['groups'];
|
|
478
|
+
if (typeof groupsRaw !== 'object' || groupsRaw === null) {
|
|
479
|
+
throw new MapError('the map places no groups');
|
|
480
|
+
}
|
|
481
|
+
const modelGroups = options.model ? colocationGroups(options.model) : [];
|
|
482
|
+
const membersOf = new Map(modelGroups.map((g) => [g.name, g.members]));
|
|
483
|
+
const groups = {};
|
|
484
|
+
// By name, not in the document's order. A map with two defects has to refuse the same way in
|
|
485
|
+
// every language (format-contract §8a): `errors/019` carries a reserved table name in one group
|
|
486
|
+
// and an auto-plus-explicit layout in another, and it pinned the first only because that group is
|
|
487
|
+
// written earlier in the file and both of our runtimes iterate an object in insertion order. A
|
|
488
|
+
// third implementation in Go, whose maps iterate in a randomised order, failed that vector in 5
|
|
489
|
+
// of 20 runs. `checkRoutingTargets` has sorted since it was written; this loop had not.
|
|
490
|
+
const groupNames = Object.keys(groupsRaw).sort(compareCodePoints);
|
|
491
|
+
for (const name of groupNames) {
|
|
492
|
+
const value = groupsRaw[name];
|
|
493
|
+
const where = `group '${name}'`;
|
|
494
|
+
const placement = asRecord(value, where);
|
|
495
|
+
if (!('source' in placement)) {
|
|
496
|
+
throw new MapError(`${where}: needs a 'source' materialisation`);
|
|
497
|
+
}
|
|
498
|
+
const resolve = (layout, what) => {
|
|
499
|
+
if (layout !== AUTO)
|
|
500
|
+
return layout;
|
|
501
|
+
const members = membersOf.get(name);
|
|
502
|
+
if (!members) {
|
|
503
|
+
throw new MapError(`${where}.${what}: a layout asked to be derived with {"auto": true}, but no model was ` +
|
|
504
|
+
'supplied to derive it from. Pass model to loadMap().');
|
|
505
|
+
}
|
|
506
|
+
return defaultLayout(options.model, members);
|
|
507
|
+
};
|
|
508
|
+
const writeEpoch = contract >= 4 ? placement['write_epoch'] : undefined;
|
|
509
|
+
if (contract >= 4 && (typeof writeEpoch !== 'number' || !Number.isSafeInteger(writeEpoch) || writeEpoch < 1)) {
|
|
510
|
+
throw new MapError(`${where}: contract 4 requires a positive safe write_epoch`);
|
|
511
|
+
}
|
|
512
|
+
if (contract < 4 && 'write_epoch' in placement) {
|
|
513
|
+
throw new MapError(`${where}: write_epoch requires placement map contract 4`);
|
|
514
|
+
}
|
|
515
|
+
const sourceRead = readMaterialization(placement['source'], `${where}.source`, true, contract);
|
|
516
|
+
const source = {
|
|
517
|
+
...sourceRead.mat,
|
|
518
|
+
layout: resolve(sourceRead.layout, 'source'),
|
|
519
|
+
};
|
|
520
|
+
const derivedRaw = (placement['derived'] ?? []);
|
|
521
|
+
const derived = derivedRaw.map((entry, i) => {
|
|
522
|
+
const read = readMaterialization(entry, `${where}.derived[${i}]`, false, contract);
|
|
523
|
+
return { ...read.mat, layout: resolve(read.layout, `derived[${i}]`) };
|
|
524
|
+
});
|
|
525
|
+
const ids = [source, ...derived].map((m) => m.id);
|
|
526
|
+
if (new Set(ids).size !== ids.length) {
|
|
527
|
+
throw new MapError(`${where}: two materialisations share an id`);
|
|
528
|
+
}
|
|
529
|
+
// A derived copy in the same engine, naming the same tables, is the source with a second name in
|
|
530
|
+
// the map: its lag would always read as zero and a read routed to it would silently be a read of
|
|
531
|
+
// the source. Found by a Python test; refused here for the same reason.
|
|
532
|
+
const sourceTables = new Set(Object.values(source.layout.tables));
|
|
533
|
+
for (const candidate of derived) {
|
|
534
|
+
if (candidate.engine !== source.engine)
|
|
535
|
+
continue;
|
|
536
|
+
const shared = Object.values(candidate.layout.tables)
|
|
537
|
+
.filter((t) => sourceTables.has(t))
|
|
538
|
+
.sort();
|
|
539
|
+
if (shared.length > 0) {
|
|
540
|
+
throw new MapError(`${where}: materialisation '${candidate.id}' is in the same engine as the source and ` +
|
|
541
|
+
`reuses its tables ${JSON.stringify(shared)}. That is not a copy of the group, it is ` +
|
|
542
|
+
'the original with a second name in the map.');
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
groups[name] = {
|
|
546
|
+
...(writeEpoch === undefined ? {} : { writeEpoch: writeEpoch }),
|
|
547
|
+
group: name,
|
|
548
|
+
source,
|
|
549
|
+
derived,
|
|
550
|
+
alsoWrite: readAlsoWrite(placement['also_write'], where, contract, source, derived),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (options.model) {
|
|
554
|
+
const declaredNames = modelGroups.map((g) => g.name);
|
|
555
|
+
const missing = declaredNames.filter((name) => !(name in groups)).sort();
|
|
556
|
+
if (missing.length > 0) {
|
|
557
|
+
throw new MapError(`the map does not place these groups: ${JSON.stringify(missing)}. Every group in the model ` +
|
|
558
|
+
'needs a home before anything can run.');
|
|
559
|
+
}
|
|
560
|
+
// And the other direction. Checking one of the two reads as complete: Python fell through to a
|
|
561
|
+
// dict lookup and produced a bare KeyError, this one accepted the map in silence. Two
|
|
562
|
+
// languages, one missing check, two different wrong answers.
|
|
563
|
+
const unknown = Object.keys(groups)
|
|
564
|
+
.filter((name) => !declaredNames.includes(name))
|
|
565
|
+
.sort();
|
|
566
|
+
if (unknown.length > 0) {
|
|
567
|
+
throw new MapError(`the map places groups this model does not have: ${JSON.stringify(unknown)}. The model ` +
|
|
568
|
+
'version matched, so this is not a stale map: the two sides derived colocation groups ' +
|
|
569
|
+
'differently, and a group nobody declared has no entities to hold.');
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const routingRaw = body['routing'] ?? {};
|
|
573
|
+
if (typeof routingRaw !== 'object' || routingRaw === null) {
|
|
574
|
+
throw new MapError("'routing' must be a mapping from shape id to materialisation id");
|
|
575
|
+
}
|
|
576
|
+
if (options.model) {
|
|
577
|
+
for (const name of Object.keys(groups).sort(compareCodePoints)) {
|
|
578
|
+
checkPhysical(groups[name], options.model);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
checkRoutingTargets(routingRaw, groups, options.model);
|
|
582
|
+
if (contract >= 4) {
|
|
583
|
+
for (const spot of Object.values(groups)) {
|
|
584
|
+
for (const materialization of [spot.source, ...spot.derived]) {
|
|
585
|
+
if (Object.values(materialization.layout.columns).some((fields) => EPOCH_COLUMN in fields)) {
|
|
586
|
+
throw new MapError('the write-epoch column is reserved for the SDK');
|
|
587
|
+
}
|
|
588
|
+
if (Object.values(materialization.layout.tables).includes(DRAIN_TABLE)) {
|
|
589
|
+
throw new MapError('the write-fence drain log is reserved for the SDK');
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
let fingerprint;
|
|
595
|
+
try {
|
|
596
|
+
const payload = { ...body };
|
|
597
|
+
delete payload['signature'];
|
|
598
|
+
fingerprint = createHash('sha256').update(canonicalBytes(payload)).digest('hex');
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
if (!(error instanceof CanonicalError))
|
|
602
|
+
throw error;
|
|
603
|
+
if (contract >= 4)
|
|
604
|
+
throw new MapError('contract 4 requires a canonically encodable placement map');
|
|
605
|
+
}
|
|
606
|
+
const result = {
|
|
607
|
+
// The document's number, not this library's. Reporting CONTRACT here said "this map declares
|
|
608
|
+
// contract 2" of a document declaring 1, and the Python field has always carried the
|
|
609
|
+
// document's.
|
|
610
|
+
contract,
|
|
611
|
+
modelVersion,
|
|
612
|
+
mapVersion,
|
|
613
|
+
...(projectId === undefined ? {} : { projectId: projectId }),
|
|
614
|
+
groups,
|
|
615
|
+
routing: routingRaw,
|
|
616
|
+
signed: signaturePresent,
|
|
617
|
+
fingerprint,
|
|
618
|
+
verifiedWith,
|
|
619
|
+
};
|
|
620
|
+
freeze(result);
|
|
621
|
+
loadedFingerprints.set(result, fingerprint);
|
|
622
|
+
return result;
|
|
623
|
+
}
|
|
624
|
+
const loadedFingerprints = new WeakMap();
|
|
625
|
+
export function fingerprintOf(map) {
|
|
626
|
+
return loadedFingerprints.get(map);
|
|
627
|
+
}
|
|
628
|
+
function freeze(value) {
|
|
629
|
+
if (typeof value !== 'object' || value === null)
|
|
630
|
+
return;
|
|
631
|
+
for (const member of Object.values(value))
|
|
632
|
+
freeze(member);
|
|
633
|
+
Object.freeze(value);
|
|
634
|
+
}
|
|
635
|
+
export function placementOf(map, group) {
|
|
636
|
+
const found = map.groups[group];
|
|
637
|
+
if (!found) {
|
|
638
|
+
throw new MapError(`no placement for group '${group}'. Every group in the model needs one: a group with nowhere ` +
|
|
639
|
+
'to live is not a slow path, it is an unanswerable operation.');
|
|
640
|
+
}
|
|
641
|
+
return found;
|
|
642
|
+
}
|
|
643
|
+
export function materializationById(placement, id) {
|
|
644
|
+
const found = [placement.source, ...placement.derived].find((m) => m.id === id);
|
|
645
|
+
if (!found) {
|
|
646
|
+
throw new MapError(`group '${placement.group}' has no materialisation '${id}', but the routing table points at ` +
|
|
647
|
+
'it. The map is internally inconsistent.');
|
|
648
|
+
}
|
|
649
|
+
return found;
|
|
650
|
+
}
|
|
651
|
+
//# sourceMappingURL=placement.js.map
|