@geoprotocol/grc-20 0.2.2 → 0.3.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/dist/codec/edit.d.ts.map +1 -1
- package/dist/codec/edit.js +516 -7
- package/dist/codec/edit.js.map +1 -1
- package/dist/codec/index.d.ts +1 -1
- package/dist/codec/index.d.ts.map +1 -1
- package/dist/codec/index.js +1 -1
- package/dist/codec/index.js.map +1 -1
- package/dist/codec/op.d.ts.map +1 -1
- package/dist/codec/op.js +5 -1
- package/dist/codec/op.js.map +1 -1
- package/dist/codec/primitives.d.ts +8 -0
- package/dist/codec/primitives.d.ts.map +1 -1
- package/dist/codec/primitives.js +16 -0
- package/dist/codec/primitives.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/test/basic.test.js +389 -1
- package/dist/test/basic.test.js.map +1 -1
- package/dist/util/datetime.d.ts +6 -0
- package/dist/util/datetime.d.ts.map +1 -1
- package/dist/util/datetime.js +16 -4
- package/dist/util/datetime.js.map +1 -1
- package/package.json +1 -1
package/dist/codec/edit.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"edit.d.ts","sourceRoot":"","sources":["../../src/codec/edit.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAwB,IAAI,EAAoB,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"edit.d.ts","sourceRoot":"","sources":["../../src/codec/edit.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAwB,IAAI,EAAoB,MAAM,kBAAkB,CAAC;AAsBrF;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAmYD;;GAEG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,UAAU,CAyI1E;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CA6CjD"}
|
package/dist/codec/edit.js
CHANGED
|
@@ -1,32 +1,526 @@
|
|
|
1
|
-
import { compareIds } from "../types/id.js";
|
|
1
|
+
import { compareIds, idsEqual } from "../types/id.js";
|
|
2
2
|
import { DataType, valueDataType } from "../types/value.js";
|
|
3
|
-
import { DecodeError, Reader, Writer } from "./primitives.js";
|
|
3
|
+
import { DecodeError, EncodeError, Reader, Writer } from "./primitives.js";
|
|
4
4
|
import { decodeOp, encodeOp } from "./op.js";
|
|
5
5
|
// Magic bytes
|
|
6
6
|
const MAGIC_UNCOMPRESSED = new TextEncoder().encode("GRC2");
|
|
7
7
|
const MAGIC_COMPRESSED = new TextEncoder().encode("GRC2Z");
|
|
8
8
|
// Current version
|
|
9
9
|
const VERSION = 0;
|
|
10
|
+
// Security limits (match Rust codec limits)
|
|
11
|
+
const MAX_STRING_LEN = 16 * 1024 * 1024;
|
|
12
|
+
const MAX_AUTHORS = 1_000;
|
|
13
|
+
const MAX_DICT_SIZE = 1_000_000;
|
|
14
|
+
const MAX_OPS_PER_EDIT = 1_000_000;
|
|
15
|
+
const MAX_VALUES_PER_ENTITY = 10_000;
|
|
16
|
+
const MAX_POSITION_LEN = 64;
|
|
17
|
+
const POSITION_RE = /^[0-9A-Za-z]+$/;
|
|
18
|
+
function assertId(value, context) {
|
|
19
|
+
if (!(value instanceof Uint8Array) || value.length !== 16) {
|
|
20
|
+
throw new EncodeError("E005", `invalid id for ${context}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function validatePosition(pos, context) {
|
|
24
|
+
if (pos.length === 0) {
|
|
25
|
+
throw new EncodeError("E005", `${context} position cannot be empty`);
|
|
26
|
+
}
|
|
27
|
+
if (pos.length > MAX_POSITION_LEN) {
|
|
28
|
+
throw new EncodeError("E005", `${context} position length ${pos.length} exceeds maximum ${MAX_POSITION_LEN}`);
|
|
29
|
+
}
|
|
30
|
+
if (!POSITION_RE.test(pos)) {
|
|
31
|
+
throw new EncodeError("E005", `${context} position contains invalid characters`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function validateContext(ctx, context) {
|
|
35
|
+
assertId(ctx.rootId, `${context}.rootId`);
|
|
36
|
+
if (!Array.isArray(ctx.edges)) {
|
|
37
|
+
throw new EncodeError("E005", `${context}.edges must be an array`);
|
|
38
|
+
}
|
|
39
|
+
if (ctx.edges.length > MAX_DICT_SIZE) {
|
|
40
|
+
throw new EncodeError("E005", `${context}.edges length ${ctx.edges.length} exceeds maximum ${MAX_DICT_SIZE}`);
|
|
41
|
+
}
|
|
42
|
+
for (let i = 0; i < ctx.edges.length; i++) {
|
|
43
|
+
const edge = ctx.edges[i];
|
|
44
|
+
assertId(edge.typeId, `${context}.edges[${i}].typeId`);
|
|
45
|
+
assertId(edge.toEntityId, `${context}.edges[${i}].toEntityId`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function validateUnsetLanguage(lang, context) {
|
|
49
|
+
switch (lang.type) {
|
|
50
|
+
case "all":
|
|
51
|
+
case "english":
|
|
52
|
+
return;
|
|
53
|
+
case "specific":
|
|
54
|
+
assertId(lang.language, `${context}.language`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function validatePropertyValue(value, context) {
|
|
59
|
+
assertId(value.property, `${context}.property`);
|
|
60
|
+
if (value.value.type === "text" && value.value.language !== undefined) {
|
|
61
|
+
assertId(value.value.language, `${context}.value.language`);
|
|
62
|
+
}
|
|
63
|
+
if ((value.value.type === "int64" || value.value.type === "float64" || value.value.type === "decimal") &&
|
|
64
|
+
value.value.unit !== undefined) {
|
|
65
|
+
assertId(value.value.unit, `${context}.value.unit`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function languageKeyForSetValue(value) {
|
|
69
|
+
if (value.value.type === "text") {
|
|
70
|
+
return value.value.language ? idKey(value.value.language) : "english";
|
|
71
|
+
}
|
|
72
|
+
return "non-text";
|
|
73
|
+
}
|
|
74
|
+
function languageKeyForUnset(lang) {
|
|
75
|
+
switch (lang.type) {
|
|
76
|
+
case "all":
|
|
77
|
+
return "all";
|
|
78
|
+
case "english":
|
|
79
|
+
return "english";
|
|
80
|
+
case "specific":
|
|
81
|
+
return idKey(lang.language);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function validateOp(op, index) {
|
|
85
|
+
const context = `op[${index}]`;
|
|
86
|
+
switch (op.type) {
|
|
87
|
+
case "createEntity":
|
|
88
|
+
assertId(op.id, `${context}.id`);
|
|
89
|
+
if (!Array.isArray(op.values)) {
|
|
90
|
+
throw new EncodeError("E005", `${context}.values must be an array`);
|
|
91
|
+
}
|
|
92
|
+
if (op.values.length > MAX_VALUES_PER_ENTITY) {
|
|
93
|
+
throw new EncodeError("E005", `${context}.values length ${op.values.length} exceeds maximum ${MAX_VALUES_PER_ENTITY}`);
|
|
94
|
+
}
|
|
95
|
+
for (let i = 0; i < op.values.length; i++) {
|
|
96
|
+
validatePropertyValue(op.values[i], `${context}.values[${i}]`);
|
|
97
|
+
}
|
|
98
|
+
if (op.context !== undefined) {
|
|
99
|
+
validateContext(op.context, `${context}.context`);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
case "updateEntity":
|
|
103
|
+
assertId(op.id, `${context}.id`);
|
|
104
|
+
if (!Array.isArray(op.set)) {
|
|
105
|
+
throw new EncodeError("E005", `${context}.set must be an array`);
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(op.unset)) {
|
|
108
|
+
throw new EncodeError("E005", `${context}.unset must be an array`);
|
|
109
|
+
}
|
|
110
|
+
if (op.set.length > MAX_VALUES_PER_ENTITY) {
|
|
111
|
+
throw new EncodeError("E005", `${context}.set length ${op.set.length} exceeds maximum ${MAX_VALUES_PER_ENTITY}`);
|
|
112
|
+
}
|
|
113
|
+
if (op.unset.length > MAX_VALUES_PER_ENTITY) {
|
|
114
|
+
throw new EncodeError("E005", `${context}.unset length ${op.unset.length} exceeds maximum ${MAX_VALUES_PER_ENTITY}`);
|
|
115
|
+
}
|
|
116
|
+
for (let i = 0; i < op.set.length; i++) {
|
|
117
|
+
validatePropertyValue(op.set[i], `${context}.set[${i}]`);
|
|
118
|
+
}
|
|
119
|
+
const setKeys = new Map();
|
|
120
|
+
for (const value of op.set) {
|
|
121
|
+
const propKey = idKey(value.property);
|
|
122
|
+
const langKey = languageKeyForSetValue(value);
|
|
123
|
+
let langs = setKeys.get(propKey);
|
|
124
|
+
if (!langs) {
|
|
125
|
+
langs = new Set();
|
|
126
|
+
setKeys.set(propKey, langs);
|
|
127
|
+
}
|
|
128
|
+
langs.add(langKey);
|
|
129
|
+
}
|
|
130
|
+
for (let i = 0; i < op.unset.length; i++) {
|
|
131
|
+
const u = op.unset[i];
|
|
132
|
+
assertId(u.property, `${context}.unset[${i}].property`);
|
|
133
|
+
validateUnsetLanguage(u.language, `${context}.unset[${i}].language`);
|
|
134
|
+
const propKey = idKey(u.property);
|
|
135
|
+
const langKey = languageKeyForUnset(u.language);
|
|
136
|
+
const setLangs = setKeys.get(propKey);
|
|
137
|
+
if (setLangs) {
|
|
138
|
+
if (langKey === "all") {
|
|
139
|
+
throw new EncodeError("E005", `${context}.unset[${i}] conflicts with set for property`);
|
|
140
|
+
}
|
|
141
|
+
if (setLangs.has(langKey)) {
|
|
142
|
+
throw new EncodeError("E005", `${context}.unset[${i}] conflicts with set for property/language`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (op.context !== undefined) {
|
|
147
|
+
validateContext(op.context, `${context}.context`);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
case "deleteEntity":
|
|
151
|
+
case "restoreEntity":
|
|
152
|
+
assertId(op.id, `${context}.id`);
|
|
153
|
+
if (op.context !== undefined) {
|
|
154
|
+
validateContext(op.context, `${context}.context`);
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
case "createRelation":
|
|
158
|
+
assertId(op.id, `${context}.id`);
|
|
159
|
+
assertId(op.relationType, `${context}.relationType`);
|
|
160
|
+
assertId(op.from, `${context}.from`);
|
|
161
|
+
assertId(op.to, `${context}.to`);
|
|
162
|
+
if (op.entity !== undefined && idsEqual(op.entity, op.id)) {
|
|
163
|
+
throw new EncodeError("E005", `${context}.entity must differ from relation id`);
|
|
164
|
+
}
|
|
165
|
+
if (op.fromIsValueRef !== undefined && typeof op.fromIsValueRef !== "boolean") {
|
|
166
|
+
throw new EncodeError("E005", `${context}.fromIsValueRef must be a boolean`);
|
|
167
|
+
}
|
|
168
|
+
if (op.toIsValueRef !== undefined && typeof op.toIsValueRef !== "boolean") {
|
|
169
|
+
throw new EncodeError("E005", `${context}.toIsValueRef must be a boolean`);
|
|
170
|
+
}
|
|
171
|
+
if (op.fromSpace !== undefined)
|
|
172
|
+
assertId(op.fromSpace, `${context}.fromSpace`);
|
|
173
|
+
if (op.fromVersion !== undefined)
|
|
174
|
+
assertId(op.fromVersion, `${context}.fromVersion`);
|
|
175
|
+
if (op.toSpace !== undefined)
|
|
176
|
+
assertId(op.toSpace, `${context}.toSpace`);
|
|
177
|
+
if (op.toVersion !== undefined)
|
|
178
|
+
assertId(op.toVersion, `${context}.toVersion`);
|
|
179
|
+
if (op.entity !== undefined)
|
|
180
|
+
assertId(op.entity, `${context}.entity`);
|
|
181
|
+
if (op.position !== undefined)
|
|
182
|
+
validatePosition(op.position, context);
|
|
183
|
+
if (op.context !== undefined) {
|
|
184
|
+
validateContext(op.context, `${context}.context`);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
case "updateRelation":
|
|
188
|
+
assertId(op.id, `${context}.id`);
|
|
189
|
+
if (op.fromSpace !== undefined)
|
|
190
|
+
assertId(op.fromSpace, `${context}.fromSpace`);
|
|
191
|
+
if (op.fromVersion !== undefined)
|
|
192
|
+
assertId(op.fromVersion, `${context}.fromVersion`);
|
|
193
|
+
if (op.toSpace !== undefined)
|
|
194
|
+
assertId(op.toSpace, `${context}.toSpace`);
|
|
195
|
+
if (op.toVersion !== undefined)
|
|
196
|
+
assertId(op.toVersion, `${context}.toVersion`);
|
|
197
|
+
if (op.position !== undefined)
|
|
198
|
+
validatePosition(op.position, context);
|
|
199
|
+
if (op.unset !== undefined) {
|
|
200
|
+
const allowed = new Set(["fromSpace", "fromVersion", "toSpace", "toVersion", "position"]);
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
for (const field of op.unset) {
|
|
203
|
+
if (!allowed.has(field)) {
|
|
204
|
+
throw new EncodeError("E005", `${context}.unset contains invalid field: ${field}`);
|
|
205
|
+
}
|
|
206
|
+
if (seen.has(field)) {
|
|
207
|
+
throw new EncodeError("E005", `${context}.unset contains duplicate field: ${field}`);
|
|
208
|
+
}
|
|
209
|
+
seen.add(field);
|
|
210
|
+
if ((field === "fromSpace" && op.fromSpace !== undefined) ||
|
|
211
|
+
(field === "fromVersion" && op.fromVersion !== undefined) ||
|
|
212
|
+
(field === "toSpace" && op.toSpace !== undefined) ||
|
|
213
|
+
(field === "toVersion" && op.toVersion !== undefined) ||
|
|
214
|
+
(field === "position" && op.position !== undefined)) {
|
|
215
|
+
throw new EncodeError("E005", `${context}.unset contains field also set in op`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (op.context !== undefined) {
|
|
220
|
+
validateContext(op.context, `${context}.context`);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
case "deleteRelation":
|
|
224
|
+
case "restoreRelation":
|
|
225
|
+
assertId(op.id, `${context}.id`);
|
|
226
|
+
if (op.context !== undefined) {
|
|
227
|
+
validateContext(op.context, `${context}.context`);
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
case "createValueRef": {
|
|
231
|
+
const opAny = op;
|
|
232
|
+
if (opAny.context !== undefined) {
|
|
233
|
+
throw new EncodeError("E005", `${context}.context is not allowed for createValueRef`);
|
|
234
|
+
}
|
|
235
|
+
assertId(op.id, `${context}.id`);
|
|
236
|
+
assertId(op.entity, `${context}.entity`);
|
|
237
|
+
assertId(op.property, `${context}.property`);
|
|
238
|
+
if (op.language !== undefined)
|
|
239
|
+
assertId(op.language, `${context}.language`);
|
|
240
|
+
if (op.space !== undefined)
|
|
241
|
+
assertId(op.space, `${context}.space`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
default: {
|
|
245
|
+
const typeValue = op.type ?? "unknown";
|
|
246
|
+
throw new EncodeError("E005", `${context} has invalid op type: ${typeValue}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Encode-time structural validation aligned with spec.md:
|
|
252
|
+
* - Section 4.3: dictionary membership, size limits, ID shape
|
|
253
|
+
* - Section 4.4: canonical rules (sorted lists, no duplicates)
|
|
254
|
+
* - Section 4.5 / 6.3: context structure and ContextRef requirements
|
|
255
|
+
* - Section 6.4: op type whitelist and context_ref support rules
|
|
256
|
+
* - Section 3.2 / 3.6: update set/unset overlap and TEXT-only language slots
|
|
257
|
+
*/
|
|
258
|
+
function validateEdit(edit, canonical) {
|
|
259
|
+
assertId(edit.id, "edit.id");
|
|
260
|
+
if (typeof edit.name !== "string") {
|
|
261
|
+
throw new EncodeError("E005", "edit.name must be a string");
|
|
262
|
+
}
|
|
263
|
+
const nameBytes = new TextEncoder().encode(edit.name).length;
|
|
264
|
+
if (nameBytes > MAX_STRING_LEN) {
|
|
265
|
+
throw new EncodeError("E005", `edit.name length ${nameBytes} exceeds maximum ${MAX_STRING_LEN}`);
|
|
266
|
+
}
|
|
267
|
+
if (!Array.isArray(edit.authors)) {
|
|
268
|
+
throw new EncodeError("E005", "edit.authors must be an array");
|
|
269
|
+
}
|
|
270
|
+
if (edit.authors.length > MAX_AUTHORS) {
|
|
271
|
+
throw new EncodeError("E005", `edit.authors length ${edit.authors.length} exceeds maximum ${MAX_AUTHORS}`);
|
|
272
|
+
}
|
|
273
|
+
for (let i = 0; i < edit.authors.length; i++) {
|
|
274
|
+
assertId(edit.authors[i], `edit.authors[${i}]`);
|
|
275
|
+
}
|
|
276
|
+
if (canonical) {
|
|
277
|
+
const sorted = [...edit.authors].sort(compareIds);
|
|
278
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
279
|
+
if (compareIds(sorted[i - 1], sorted[i]) === 0) {
|
|
280
|
+
throw new EncodeError("E005", "edit.authors contains duplicate IDs in canonical mode");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (typeof edit.createdAt !== "bigint") {
|
|
285
|
+
throw new EncodeError("E005", "edit.createdAt must be a bigint");
|
|
286
|
+
}
|
|
287
|
+
if (!Array.isArray(edit.ops)) {
|
|
288
|
+
throw new EncodeError("E005", "edit.ops must be an array");
|
|
289
|
+
}
|
|
290
|
+
if (edit.ops.length > MAX_OPS_PER_EDIT) {
|
|
291
|
+
throw new EncodeError("E005", `edit.ops length ${edit.ops.length} exceeds maximum ${MAX_OPS_PER_EDIT}`);
|
|
292
|
+
}
|
|
293
|
+
const propertyTypes = new Map();
|
|
294
|
+
const deletedEntities = new Set();
|
|
295
|
+
const deletedRelations = new Set();
|
|
296
|
+
for (let i = 0; i < edit.ops.length; i++) {
|
|
297
|
+
const op = edit.ops[i];
|
|
298
|
+
validateOp(op, i);
|
|
299
|
+
if (op.type === "createEntity") {
|
|
300
|
+
const idKeyValue = idKey(op.id);
|
|
301
|
+
if (deletedEntities.has(idKeyValue)) {
|
|
302
|
+
throw new EncodeError("E005", "delete-then-create entity in same edit");
|
|
303
|
+
}
|
|
304
|
+
for (const pv of op.values) {
|
|
305
|
+
const key = idKey(pv.property);
|
|
306
|
+
const dt = valueDataType(pv.value);
|
|
307
|
+
const existing = propertyTypes.get(key);
|
|
308
|
+
if (existing !== undefined && existing !== dt) {
|
|
309
|
+
throw new EncodeError("E005", `property type mismatch for ${key}`);
|
|
310
|
+
}
|
|
311
|
+
propertyTypes.set(key, dt);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
else if (op.type === "updateEntity") {
|
|
315
|
+
for (const pv of op.set) {
|
|
316
|
+
const key = idKey(pv.property);
|
|
317
|
+
const dt = valueDataType(pv.value);
|
|
318
|
+
const existing = propertyTypes.get(key);
|
|
319
|
+
if (existing !== undefined && existing !== dt) {
|
|
320
|
+
throw new EncodeError("E005", `property type mismatch for ${key}`);
|
|
321
|
+
}
|
|
322
|
+
propertyTypes.set(key, dt);
|
|
323
|
+
}
|
|
324
|
+
for (const u of op.unset) {
|
|
325
|
+
if (u.language.type !== "all") {
|
|
326
|
+
const key = idKey(u.property);
|
|
327
|
+
const existing = propertyTypes.get(key);
|
|
328
|
+
if (existing !== undefined && existing !== DataType.Text) {
|
|
329
|
+
throw new EncodeError("E005", `unset language requires TEXT property for ${key}`);
|
|
330
|
+
}
|
|
331
|
+
if (existing === undefined) {
|
|
332
|
+
propertyTypes.set(key, DataType.Text);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else if (op.type === "deleteEntity") {
|
|
338
|
+
deletedEntities.add(idKey(op.id));
|
|
339
|
+
}
|
|
340
|
+
else if (op.type === "createRelation") {
|
|
341
|
+
const idKeyValue = idKey(op.id);
|
|
342
|
+
if (deletedRelations.has(idKeyValue)) {
|
|
343
|
+
throw new EncodeError("E005", "delete-then-create relation in same edit");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else if (op.type === "deleteRelation") {
|
|
347
|
+
deletedRelations.add(idKey(op.id));
|
|
348
|
+
}
|
|
349
|
+
else if (op.type === "createValueRef") {
|
|
350
|
+
if (op.language !== undefined) {
|
|
351
|
+
const key = idKey(op.property);
|
|
352
|
+
const existing = propertyTypes.get(key);
|
|
353
|
+
if (existing !== undefined && existing !== DataType.Text) {
|
|
354
|
+
throw new EncodeError("E005", `createValueRef language requires TEXT property for ${key}`);
|
|
355
|
+
}
|
|
356
|
+
if (existing === undefined) {
|
|
357
|
+
propertyTypes.set(key, DataType.Text);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (canonical) {
|
|
363
|
+
const makeSetKey = (value) => `${idKey(value.property)}|${languageKeyForSetValue(value)}`;
|
|
364
|
+
const makeUnsetKey = (unset, property) => `${idKey(property)}|${languageKeyForUnset(unset)}`;
|
|
365
|
+
for (const op of edit.ops) {
|
|
366
|
+
if (op.type === "createEntity") {
|
|
367
|
+
const seen = new Set();
|
|
368
|
+
for (const pv of op.values) {
|
|
369
|
+
const key = makeSetKey(pv);
|
|
370
|
+
if (seen.has(key)) {
|
|
371
|
+
throw new EncodeError("E005", "duplicate (property, language) in createEntity.values (canonical)");
|
|
372
|
+
}
|
|
373
|
+
seen.add(key);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
else if (op.type === "updateEntity") {
|
|
377
|
+
const seenSet = new Set();
|
|
378
|
+
for (const pv of op.set) {
|
|
379
|
+
const key = makeSetKey(pv);
|
|
380
|
+
if (seenSet.has(key)) {
|
|
381
|
+
throw new EncodeError("E005", "duplicate (property, language) in updateEntity.set (canonical)");
|
|
382
|
+
}
|
|
383
|
+
seenSet.add(key);
|
|
384
|
+
}
|
|
385
|
+
const seenUnset = new Set();
|
|
386
|
+
for (const u of op.unset) {
|
|
387
|
+
const key = makeUnsetKey(u.language, u.property);
|
|
388
|
+
if (seenUnset.has(key)) {
|
|
389
|
+
throw new EncodeError("E005", "duplicate (property, language) in updateEntity.unset (canonical)");
|
|
390
|
+
}
|
|
391
|
+
seenUnset.add(key);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else if (op.type === "updateRelation" && op.unset) {
|
|
395
|
+
const seen = new Set();
|
|
396
|
+
for (const field of op.unset) {
|
|
397
|
+
if (seen.has(field)) {
|
|
398
|
+
throw new EncodeError("E005", "duplicate unset field in updateRelation (canonical)");
|
|
399
|
+
}
|
|
400
|
+
seen.add(field);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
10
406
|
/**
|
|
11
407
|
* Encodes an Edit to binary format.
|
|
12
408
|
*/
|
|
13
409
|
export function encodeEdit(edit, options) {
|
|
14
410
|
const canonical = options?.canonical ?? false;
|
|
411
|
+
validateEdit(edit, canonical);
|
|
15
412
|
// Build dictionaries by scanning all ops (contexts are collected from ops)
|
|
16
413
|
let dicts = buildDictionaries(edit.ops);
|
|
17
414
|
// Sort dictionaries for canonical encoding
|
|
18
415
|
if (canonical) {
|
|
19
416
|
dicts = sortDictionaries(dicts);
|
|
20
417
|
}
|
|
418
|
+
const canonicalizeOps = (ops) => {
|
|
419
|
+
const sortedOps = [];
|
|
420
|
+
for (const op of ops) {
|
|
421
|
+
if (op.type === "createEntity") {
|
|
422
|
+
const values = [...op.values].sort((a, b) => {
|
|
423
|
+
const propCmp = compareIds(a.property, b.property);
|
|
424
|
+
if (propCmp !== 0)
|
|
425
|
+
return propCmp;
|
|
426
|
+
const aLang = a.value.type === "text" ? a.value.language : undefined;
|
|
427
|
+
const bLang = b.value.type === "text" ? b.value.language : undefined;
|
|
428
|
+
if (aLang === undefined && bLang === undefined)
|
|
429
|
+
return 0;
|
|
430
|
+
if (aLang === undefined)
|
|
431
|
+
return -1;
|
|
432
|
+
if (bLang === undefined)
|
|
433
|
+
return 1;
|
|
434
|
+
return compareIds(aLang, bLang);
|
|
435
|
+
});
|
|
436
|
+
sortedOps.push({ ...op, values });
|
|
437
|
+
}
|
|
438
|
+
else if (op.type === "updateEntity") {
|
|
439
|
+
const set = [...op.set].sort((a, b) => {
|
|
440
|
+
const propCmp = compareIds(a.property, b.property);
|
|
441
|
+
if (propCmp !== 0)
|
|
442
|
+
return propCmp;
|
|
443
|
+
const aLang = a.value.type === "text" ? a.value.language : undefined;
|
|
444
|
+
const bLang = b.value.type === "text" ? b.value.language : undefined;
|
|
445
|
+
if (aLang === undefined && bLang === undefined)
|
|
446
|
+
return 0;
|
|
447
|
+
if (aLang === undefined)
|
|
448
|
+
return -1;
|
|
449
|
+
if (bLang === undefined)
|
|
450
|
+
return 1;
|
|
451
|
+
return compareIds(aLang, bLang);
|
|
452
|
+
});
|
|
453
|
+
const unset = [...op.unset].sort((a, b) => {
|
|
454
|
+
const propCmp = compareIds(a.property, b.property);
|
|
455
|
+
if (propCmp !== 0)
|
|
456
|
+
return propCmp;
|
|
457
|
+
const aKey = languageKeyForUnset(a.language);
|
|
458
|
+
const bKey = languageKeyForUnset(b.language);
|
|
459
|
+
if (aKey === bKey)
|
|
460
|
+
return 0;
|
|
461
|
+
if (aKey === "all")
|
|
462
|
+
return 1;
|
|
463
|
+
if (bKey === "all")
|
|
464
|
+
return -1;
|
|
465
|
+
if (aKey === "english")
|
|
466
|
+
return bKey === "english" ? 0 : -1;
|
|
467
|
+
if (bKey === "english")
|
|
468
|
+
return 1;
|
|
469
|
+
return aKey.localeCompare(bKey);
|
|
470
|
+
});
|
|
471
|
+
sortedOps.push({ ...op, set, unset });
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
sortedOps.push(op);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return sortedOps;
|
|
478
|
+
};
|
|
479
|
+
const opsToEncode = canonical ? canonicalizeOps(edit.ops) : edit.ops;
|
|
21
480
|
// Create dictionary indices (with context collection support)
|
|
22
481
|
const { indices, getContexts } = createDictionaryIndices(dicts);
|
|
23
482
|
// First pass: encode ops to collect contexts
|
|
24
|
-
const opsWriter = new Writer(
|
|
25
|
-
for (const op of
|
|
483
|
+
const opsWriter = new Writer(opsToEncode.length * 50);
|
|
484
|
+
for (const op of opsToEncode) {
|
|
26
485
|
encodeOp(opsWriter, op, indices);
|
|
27
486
|
}
|
|
28
487
|
const opsBytes = opsWriter.finish();
|
|
29
488
|
const contexts = getContexts();
|
|
489
|
+
if (dicts.properties.size > MAX_DICT_SIZE) {
|
|
490
|
+
throw new EncodeError("E005", `properties dictionary size ${dicts.properties.size} exceeds maximum ${MAX_DICT_SIZE}`);
|
|
491
|
+
}
|
|
492
|
+
if (dicts.relationTypes.size > MAX_DICT_SIZE) {
|
|
493
|
+
throw new EncodeError("E005", `relationTypes dictionary size ${dicts.relationTypes.size} exceeds maximum ${MAX_DICT_SIZE}`);
|
|
494
|
+
}
|
|
495
|
+
if (dicts.languages.size > MAX_DICT_SIZE) {
|
|
496
|
+
throw new EncodeError("E005", `languages dictionary size ${dicts.languages.size} exceeds maximum ${MAX_DICT_SIZE}`);
|
|
497
|
+
}
|
|
498
|
+
if (dicts.units.size > MAX_DICT_SIZE) {
|
|
499
|
+
throw new EncodeError("E005", `units dictionary size ${dicts.units.size} exceeds maximum ${MAX_DICT_SIZE}`);
|
|
500
|
+
}
|
|
501
|
+
if (dicts.objects.size > MAX_DICT_SIZE) {
|
|
502
|
+
throw new EncodeError("E005", `objects dictionary size ${dicts.objects.size} exceeds maximum ${MAX_DICT_SIZE}`);
|
|
503
|
+
}
|
|
504
|
+
if (dicts.contextIds.size > MAX_DICT_SIZE) {
|
|
505
|
+
throw new EncodeError("E005", `contextIds dictionary size ${dicts.contextIds.size} exceeds maximum ${MAX_DICT_SIZE}`);
|
|
506
|
+
}
|
|
507
|
+
if (contexts.length > MAX_DICT_SIZE) {
|
|
508
|
+
throw new EncodeError("E005", `contexts length ${contexts.length} exceeds maximum ${MAX_DICT_SIZE}`);
|
|
509
|
+
}
|
|
510
|
+
for (let i = 0; i < contexts.length; i++) {
|
|
511
|
+
validateContext(contexts[i], `contexts[${i}]`);
|
|
512
|
+
// Ensure indices are resolvable (dictionary requirement).
|
|
513
|
+
try {
|
|
514
|
+
indices.getContextIdIndex(contexts[i].rootId);
|
|
515
|
+
for (const edge of contexts[i].edges) {
|
|
516
|
+
indices.getRelationTypeIndex(edge.typeId);
|
|
517
|
+
indices.getContextIdIndex(edge.toEntityId);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
throw new EncodeError("E005", `context dictionary validation failed: ${err.message}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
30
524
|
// Write to buffer
|
|
31
525
|
const writer = new Writer(1024);
|
|
32
526
|
// Magic + version
|
|
@@ -47,7 +541,7 @@ export function encodeEdit(edit, options) {
|
|
|
47
541
|
// Contexts (collected from ops during encoding)
|
|
48
542
|
writeContexts(writer, contexts, indices);
|
|
49
543
|
// Operations (already encoded)
|
|
50
|
-
writer.writeVarintNumber(
|
|
544
|
+
writer.writeVarintNumber(opsToEncode.length);
|
|
51
545
|
writer.writeBytes(opsBytes);
|
|
52
546
|
return writer.finish();
|
|
53
547
|
}
|
|
@@ -181,8 +675,12 @@ function buildDictionaries(ops) {
|
|
|
181
675
|
case "createRelation":
|
|
182
676
|
// For unique mode, compute the derived ID and add to objects if referenced later
|
|
183
677
|
addRelationType(op.relationType);
|
|
184
|
-
|
|
185
|
-
|
|
678
|
+
if (!op.fromIsValueRef) {
|
|
679
|
+
addObject(op.from);
|
|
680
|
+
}
|
|
681
|
+
if (!op.toIsValueRef) {
|
|
682
|
+
addObject(op.to);
|
|
683
|
+
}
|
|
186
684
|
// Many mode ID is inline
|
|
187
685
|
// Entity is inline if present
|
|
188
686
|
break;
|
|
@@ -191,6 +689,17 @@ function buildDictionaries(ops) {
|
|
|
191
689
|
case "restoreRelation":
|
|
192
690
|
addObject(op.id);
|
|
193
691
|
break;
|
|
692
|
+
case "createValueRef":
|
|
693
|
+
addObject(op.entity);
|
|
694
|
+
addProperty(op.property, op.language ? DataType.Text : DataType.Bool);
|
|
695
|
+
if (op.language) {
|
|
696
|
+
addLanguage(op.language);
|
|
697
|
+
}
|
|
698
|
+
break;
|
|
699
|
+
default: {
|
|
700
|
+
const typeValue = op.type ?? "unknown";
|
|
701
|
+
throw new EncodeError("E005", `invalid op type: ${typeValue}`);
|
|
702
|
+
}
|
|
194
703
|
}
|
|
195
704
|
}
|
|
196
705
|
return dicts;
|