@opendatalabs/personal-server-ts-stream-sync 0.0.0-stage → 0.0.1-canary.14eb2b2
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/README.md +73 -2
- package/dist/bytes.d.ts +43 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +45 -0
- package/dist/bytes.js.map +1 -0
- package/dist/control.d.ts +38 -0
- package/dist/control.d.ts.map +1 -0
- package/dist/control.js +45 -0
- package/dist/control.js.map +1 -0
- package/dist/decide.d.ts +108 -0
- package/dist/decide.d.ts.map +1 -0
- package/dist/decide.js +144 -0
- package/dist/decide.js.map +1 -0
- package/dist/eligible.d.ts +67 -0
- package/dist/eligible.d.ts.map +1 -0
- package/dist/eligible.js +60 -0
- package/dist/eligible.js.map +1 -0
- package/dist/errors.d.ts +41 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +59 -0
- package/dist/errors.js.map +1 -0
- package/dist/head.d.ts +160 -0
- package/dist/head.d.ts.map +1 -0
- package/dist/head.js +315 -0
- package/dist/head.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +20 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +67 -0
- package/dist/json.js.map +1 -0
- package/dist/master-key.d.ts +21 -0
- package/dist/master-key.d.ts.map +1 -0
- package/dist/master-key.js +76 -0
- package/dist/master-key.js.map +1 -0
- package/dist/merge.d.ts +70 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +80 -0
- package/dist/merge.js.map +1 -0
- package/dist/names.d.ts +44 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +105 -0
- package/dist/names.js.map +1 -0
- package/dist/namespace.d.ts +32 -0
- package/dist/namespace.d.ts.map +1 -0
- package/dist/namespace.js +51 -0
- package/dist/namespace.js.map +1 -0
- package/dist/needs-full.d.ts +34 -0
- package/dist/needs-full.d.ts.map +1 -0
- package/dist/needs-full.js +52 -0
- package/dist/needs-full.js.map +1 -0
- package/dist/outbox.d.ts +67 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +57 -0
- package/dist/outbox.js.map +1 -0
- package/dist/parts.d.ts +37 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +93 -0
- package/dist/parts.js.map +1 -0
- package/dist/planner.d.ts +56 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +127 -0
- package/dist/planner.js.map +1 -0
- package/dist/records.d.ts +51 -0
- package/dist/records.d.ts.map +1 -0
- package/dist/records.js +113 -0
- package/dist/records.js.map +1 -0
- package/dist/vectors.d.ts +24 -0
- package/dist/vectors.d.ts.map +1 -0
- package/dist/vectors.js +289 -0
- package/dist/vectors.js.map +1 -0
- package/package.json +36 -4
- package/vectors/v1.json +4563 -0
package/dist/eligible.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §2.10 the one eligibility predicate, used by reads, consent readiness and
|
|
3
|
+
* memory totals.
|
|
4
|
+
*
|
|
5
|
+
* For connector c and a requested name:
|
|
6
|
+
* 1. If every stream the name needs is live and `complete` in the selected
|
|
7
|
+
* connection → serve the stream view. A legacy file is never compared
|
|
8
|
+
* with a complete view (one-way).
|
|
9
|
+
* 2. Else, if any connection of c is deleted and not since reconnected →
|
|
10
|
+
* `unavailable` (no legacy or raw fallback).
|
|
11
|
+
* 3. Else, if a legacy or raw file of that name exists → serve it.
|
|
12
|
+
* 4. Else `unavailable`.
|
|
13
|
+
*
|
|
14
|
+
* Selected connection: live (control `active`, so never `foreign`) with the
|
|
15
|
+
* most recently registered heads. A physical name selects its own connection.
|
|
16
|
+
*/
|
|
17
|
+
import { InvalidArgumentError } from "./errors.js";
|
|
18
|
+
import { hasOwn } from "./json.js";
|
|
19
|
+
function rank(a, b) {
|
|
20
|
+
const ra = a.latestHeadRegistration ?? -Infinity;
|
|
21
|
+
const rb = b.latestHeadRegistration ?? -Infinity;
|
|
22
|
+
if (ra !== rb)
|
|
23
|
+
return rb > ra ? 1 : -1;
|
|
24
|
+
// Tie: lowest ns (DESIGN-QUESTIONS.md Q6).
|
|
25
|
+
return a.ns < b.ns ? -1 : a.ns > b.ns ? 1 : 0;
|
|
26
|
+
}
|
|
27
|
+
/** The connection a plain (logical) name of the connector reads. */
|
|
28
|
+
export function selectConnection(connections) {
|
|
29
|
+
const live = connections.filter((c) => c.control.kind === "active");
|
|
30
|
+
return live.length === 0 ? null : [...live].sort(rank)[0];
|
|
31
|
+
}
|
|
32
|
+
export function eligible(input) {
|
|
33
|
+
const { connections, target } = input;
|
|
34
|
+
if (target.streams.length === 0) {
|
|
35
|
+
throw new InvalidArgumentError("a read target needs at least one stream");
|
|
36
|
+
}
|
|
37
|
+
const selected = target.kind === "physical"
|
|
38
|
+
? (connections.find((c) => c.ns === target.ns && c.control.kind === "active") ?? null)
|
|
39
|
+
: selectConnection(connections);
|
|
40
|
+
if (selected &&
|
|
41
|
+
target.streams.every((s) => {
|
|
42
|
+
if (!hasOwn(selected.streams, s))
|
|
43
|
+
return false;
|
|
44
|
+
const head = selected.streams[s];
|
|
45
|
+
return head.live && head.complete;
|
|
46
|
+
})) {
|
|
47
|
+
return { kind: "view", ns: selected.ns };
|
|
48
|
+
}
|
|
49
|
+
if (connections.some((c) => c.control.kind === "deleted")) {
|
|
50
|
+
return { kind: "unavailable", reason: "deleted" };
|
|
51
|
+
}
|
|
52
|
+
if (input.legacyFile)
|
|
53
|
+
return { kind: "legacy" };
|
|
54
|
+
return { kind: "unavailable", reason: "no_data" };
|
|
55
|
+
}
|
|
56
|
+
/** Readiness is exactly "a read would not be `unavailable`". */
|
|
57
|
+
export function isReady(input) {
|
|
58
|
+
return eligible(input).kind !== "unavailable";
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=eligible.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"eligible.js","sourceRoot":"","sources":["../src/eligible.ts"],"names":[],"mappings":"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"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failures. Every error carries a stable `code` so callers in another
|
|
3
|
+
* bundle can switch on it without `instanceof`.
|
|
4
|
+
*
|
|
5
|
+
* Errors are for inputs a caller must never pass (a malformed name, a value
|
|
6
|
+
* outside JSON). Expected outcomes of the protocol (`key_mismatch`,
|
|
7
|
+
* `record_too_large`, `unsupported_format`, ...) are returned as values.
|
|
8
|
+
*/
|
|
9
|
+
export type StreamSyncErrorCode = "non-canonical-value" | "invalid-name" | "invalid-record" | "missing-key-field" | "invalid-line" | "invalid-part" | "invalid-argument";
|
|
10
|
+
export declare class StreamSyncError extends Error {
|
|
11
|
+
readonly code: StreamSyncErrorCode;
|
|
12
|
+
constructor(code: StreamSyncErrorCode, message: string);
|
|
13
|
+
}
|
|
14
|
+
/** A value outside the JSON model (undefined, NaN, Date, cycles, ...). */
|
|
15
|
+
export declare class NonCanonicalValueError extends StreamSyncError {
|
|
16
|
+
constructor(message: string);
|
|
17
|
+
}
|
|
18
|
+
/** A name cannot be built: a permanent error, never retried. */
|
|
19
|
+
export declare class InvalidNameError extends StreamSyncError {
|
|
20
|
+
constructor(message: string);
|
|
21
|
+
}
|
|
22
|
+
export declare class InvalidRecordError extends StreamSyncError {
|
|
23
|
+
constructor(message: string);
|
|
24
|
+
}
|
|
25
|
+
/** A record has no own property for one of the primary-key fields. */
|
|
26
|
+
export declare class MissingKeyFieldError extends StreamSyncError {
|
|
27
|
+
readonly field: string;
|
|
28
|
+
constructor(field: string);
|
|
29
|
+
}
|
|
30
|
+
/** A part line is not `{"o":<ms>,"r":"<runId>","v":<record>}` in canonical form. */
|
|
31
|
+
export declare class InvalidLineError extends StreamSyncError {
|
|
32
|
+
constructor(message: string);
|
|
33
|
+
}
|
|
34
|
+
/** Part bytes do not match their head entry, or break the part grammar. */
|
|
35
|
+
export declare class InvalidPartError extends StreamSyncError {
|
|
36
|
+
constructor(message: string);
|
|
37
|
+
}
|
|
38
|
+
export declare class InvalidArgumentError extends StreamSyncError {
|
|
39
|
+
constructor(message: string);
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,MAAM,mBAAmB,GAC3B,qBAAqB,GACrB,cAAc,GACd,gBAAgB,GAChB,mBAAmB,GACnB,cAAc,GACd,cAAc,GACd,kBAAkB,CAAC;AAEvB,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;gBAEvB,IAAI,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM;CAKvD;AAED,0EAA0E;AAC1E,qBAAa,sBAAuB,SAAQ,eAAe;gBAC7C,OAAO,EAAE,MAAM;CAG5B;AAED,gEAAgE;AAChE,qBAAa,gBAAiB,SAAQ,eAAe;gBACvC,OAAO,EAAE,MAAM;CAG5B;AAED,qBAAa,kBAAmB,SAAQ,eAAe;gBACzC,OAAO,EAAE,MAAM;CAG5B;AAED,sEAAsE;AACtE,qBAAa,oBAAqB,SAAQ,eAAe;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;gBAEX,KAAK,EAAE,MAAM;CAI1B;AAED,oFAAoF;AACpF,qBAAa,gBAAiB,SAAQ,eAAe;gBACvC,OAAO,EAAE,MAAM;CAG5B;AAED,2EAA2E;AAC3E,qBAAa,gBAAiB,SAAQ,eAAe;gBACvC,OAAO,EAAE,MAAM;CAG5B;AAED,qBAAa,oBAAqB,SAAQ,eAAe;gBAC3C,OAAO,EAAE,MAAM;CAG5B"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failures. Every error carries a stable `code` so callers in another
|
|
3
|
+
* bundle can switch on it without `instanceof`.
|
|
4
|
+
*
|
|
5
|
+
* Errors are for inputs a caller must never pass (a malformed name, a value
|
|
6
|
+
* outside JSON). Expected outcomes of the protocol (`key_mismatch`,
|
|
7
|
+
* `record_too_large`, `unsupported_format`, ...) are returned as values.
|
|
8
|
+
*/
|
|
9
|
+
export class StreamSyncError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = new.target.name;
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** A value outside the JSON model (undefined, NaN, Date, cycles, ...). */
|
|
18
|
+
export class NonCanonicalValueError extends StreamSyncError {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super("non-canonical-value", message);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** A name cannot be built: a permanent error, never retried. */
|
|
24
|
+
export class InvalidNameError extends StreamSyncError {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super("invalid-name", message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class InvalidRecordError extends StreamSyncError {
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super("invalid-record", message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** A record has no own property for one of the primary-key fields. */
|
|
35
|
+
export class MissingKeyFieldError extends StreamSyncError {
|
|
36
|
+
field;
|
|
37
|
+
constructor(field) {
|
|
38
|
+
super("missing-key-field", `record has no value for key field "${field}"`);
|
|
39
|
+
this.field = field;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** A part line is not `{"o":<ms>,"r":"<runId>","v":<record>}` in canonical form. */
|
|
43
|
+
export class InvalidLineError extends StreamSyncError {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super("invalid-line", message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Part bytes do not match their head entry, or break the part grammar. */
|
|
49
|
+
export class InvalidPartError extends StreamSyncError {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super("invalid-part", message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export class InvalidArgumentError extends StreamSyncError {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super("invalid-argument", message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAWH,MAAM,OAAO,eAAgB,SAAQ,KAAK;IAC/B,IAAI,CAAsB;IAEnC,YAAY,IAAyB,EAAE,OAAe;QACpD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,0EAA0E;AAC1E,MAAM,OAAO,sBAAuB,SAAQ,eAAe;IACzD,YAAY,OAAe;QACzB,KAAK,CAAC,qBAAqB,EAAE,OAAO,CAAC,CAAC;IACxC,CAAC;CACF;AAED,gEAAgE;AAChE,MAAM,OAAO,gBAAiB,SAAQ,eAAe;IACnD,YAAY,OAAe;QACzB,KAAK,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;IACjC,CAAC;CACF;AAED,MAAM,OAAO,kBAAmB,SAAQ,eAAe;IACrD,YAAY,OAAe;QACzB,KAAK,CAAC,gBAAgB,EAAE,OAAO,CAAC,CAAC;IACnC,CAAC;CACF;AAED,sEAAsE;AACtE,MAAM,OAAO,oBAAqB,SAAQ,eAAe;IAC9C,KAAK,CAAS;IAEvB,YAAY,KAAa;QACvB,KAAK,CAAC,mBAAmB,EAAE,sCAAsC,KAAK,GAAG,CAAC,CAAC;QAC3E,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACrB,CAAC;CACF;AAED,oFAAoF;AACpF,MAAM,OAAO,gBAAiB,SAAQ,eAAe;IACnD,YAAY,OAAe;QACzB,KAAK,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;IACjC,CAAC;CACF;AAED,2EAA2E;AAC3E,MAAM,OAAO,gBAAiB,SAAQ,eAAe;IACnD,YAAY,OAAe;QACzB,KAAK,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;IACjC,CAAC;CACF;AAED,MAAM,OAAO,oBAAqB,SAAQ,eAAe;IACvD,YAAY,OAAe;QACzB,KAAK,CAAC,kBAAkB,EAAE,OAAO,CAAC,CAAC;IACrC,CAAC;CACF"}
|
package/dist/head.d.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §2.5 head format 1, and the head rules of §2.4, §2.5, §2.6 and §2.7:
|
|
3
|
+
* liveness, base choice, the compatible-writer rule, `complete` and no-op.
|
|
4
|
+
*
|
|
5
|
+
* Clear head bytes are canonical JSON:
|
|
6
|
+
*
|
|
7
|
+
* {"complete":bool,"connector":c,"format":1,"generation":"<dec>",
|
|
8
|
+
* "keyCheck":"<16 hex>","ns":ns,"observedAt":<ms>,
|
|
9
|
+
* "parts":[{"bytes":n,"count":n,"hash":"<sha256>","prefix":"<bits>"}],
|
|
10
|
+
* "primaryKey":[...],"profile":{"key":<string|null>,"version":"<v>"},
|
|
11
|
+
* "recordCount":n,"stream":s}
|
|
12
|
+
*
|
|
13
|
+
* The registry `dataHash` of a head is Keccak-256 of these bytes; its blob
|
|
14
|
+
* name is `h-<that hash in hex>`.
|
|
15
|
+
*/
|
|
16
|
+
import { type Keccak256 } from "./bytes.js";
|
|
17
|
+
import type { HeadPart } from "./parts.js";
|
|
18
|
+
import type { ControlState } from "./control.js";
|
|
19
|
+
export declare const HEAD_FORMAT = 1;
|
|
20
|
+
export interface HeadProfile {
|
|
21
|
+
/** The legacy projector's profile key, or `null`. */
|
|
22
|
+
readonly key: string | null;
|
|
23
|
+
/** The connector version that wrote the head. */
|
|
24
|
+
readonly version: string;
|
|
25
|
+
}
|
|
26
|
+
export interface Head {
|
|
27
|
+
readonly complete: boolean;
|
|
28
|
+
readonly connector: string;
|
|
29
|
+
readonly format: 1;
|
|
30
|
+
/** Positive integer; written as a decimal string. */
|
|
31
|
+
readonly generation: number;
|
|
32
|
+
readonly keyCheck: string;
|
|
33
|
+
readonly ns: string;
|
|
34
|
+
readonly observedAt: number;
|
|
35
|
+
readonly parts: readonly HeadPart[];
|
|
36
|
+
readonly primaryKey: readonly string[];
|
|
37
|
+
readonly profile: HeadProfile;
|
|
38
|
+
readonly recordCount: number;
|
|
39
|
+
readonly stream: string;
|
|
40
|
+
}
|
|
41
|
+
/** Canonical clear head bytes. Throws on a head this package would reject. */
|
|
42
|
+
export declare function encodeHead(head: Head): Uint8Array;
|
|
43
|
+
export interface HeadDigest {
|
|
44
|
+
/** Registry form: `0x` + 64 lowercase hex. */
|
|
45
|
+
readonly dataHash: `0x${string}`;
|
|
46
|
+
/** `h-<hex>`. */
|
|
47
|
+
readonly blobName: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function digestHead(keccak256: Keccak256, bytes: Uint8Array): Promise<HeadDigest>;
|
|
50
|
+
export type DecodedHead = {
|
|
51
|
+
readonly kind: "ok";
|
|
52
|
+
readonly head: Head;
|
|
53
|
+
} | {
|
|
54
|
+
readonly kind: "unsupported_format";
|
|
55
|
+
readonly format: unknown;
|
|
56
|
+
} | {
|
|
57
|
+
readonly kind: "malformed";
|
|
58
|
+
readonly reason: string;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Parse clear head bytes. Any `format` other than 1 is `unsupported_format`.
|
|
62
|
+
* A format-1 head must be canonical, so every host derives the same hash.
|
|
63
|
+
*/
|
|
64
|
+
export declare function decodeHead(bytes: Uint8Array): DecodedHead;
|
|
65
|
+
export type VerifiedHead = {
|
|
66
|
+
readonly kind: "ok";
|
|
67
|
+
readonly head: Head;
|
|
68
|
+
} | {
|
|
69
|
+
readonly kind: "hash_mismatch";
|
|
70
|
+
} | {
|
|
71
|
+
readonly kind: "unsupported_format";
|
|
72
|
+
readonly format: unknown;
|
|
73
|
+
} | {
|
|
74
|
+
readonly kind: "malformed";
|
|
75
|
+
readonly reason: string;
|
|
76
|
+
} | {
|
|
77
|
+
readonly kind: "name_mismatch";
|
|
78
|
+
} | {
|
|
79
|
+
readonly kind: "key_mismatch";
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* §2.5 reader checks on decrypted head bytes: `keccak = dataHash`, format,
|
|
83
|
+
* shape, name fields against the scope, and `keyCheck`. Part SHA-256 is
|
|
84
|
+
* checked per part by `decodePart`.
|
|
85
|
+
*/
|
|
86
|
+
export declare function verifyHead(keccak256: Keccak256, bytes: Uint8Array, expected: {
|
|
87
|
+
readonly dataHash: string;
|
|
88
|
+
readonly connector: string;
|
|
89
|
+
readonly ns: string;
|
|
90
|
+
readonly stream: string;
|
|
91
|
+
readonly keyCheck: string;
|
|
92
|
+
}): Promise<VerifiedHead>;
|
|
93
|
+
/** The stream row as read from the registry. */
|
|
94
|
+
export type StreamRow = "absent" | "tombstoned" | "present";
|
|
95
|
+
/**
|
|
96
|
+
* §2.4: a stream head is live iff the control is `active(g)`, the stream row
|
|
97
|
+
* is not tombstoned, and the head's generation is g.
|
|
98
|
+
*/
|
|
99
|
+
export declare function isHeadLive(control: ControlState, streamRow: StreamRow, headGeneration: number | null): boolean;
|
|
100
|
+
export type BaseChoice =
|
|
101
|
+
/** The stream row's head is of the target generation: merge onto it. */
|
|
102
|
+
{
|
|
103
|
+
readonly kind: "base";
|
|
104
|
+
readonly head: Head;
|
|
105
|
+
}
|
|
106
|
+
/** Row absent or tombstoned, or its head is older: fresh generation. */
|
|
107
|
+
| {
|
|
108
|
+
readonly kind: "empty";
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The row's head is of a NEWER generation than the target: the run's start
|
|
112
|
+
* snapshot is older than the registry. Do not publish; re-read control and
|
|
113
|
+
* decide with phase `stale_base` (P0-RULINGS F1).
|
|
114
|
+
*/
|
|
115
|
+
| {
|
|
116
|
+
readonly kind: "stale_generation";
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* §2.7 with ruling F1: three outcomes, never two.
|
|
120
|
+
*
|
|
121
|
+
* - row absent or tombstoned, or `head.generation < target` → `empty`
|
|
122
|
+
* - `head.generation == target` → `base`
|
|
123
|
+
* - `head.generation > target` → `stale_generation`
|
|
124
|
+
*
|
|
125
|
+
* A present row needs its verified head: a host that cannot read the head of
|
|
126
|
+
* a present row must not call this (it would take an empty base over data).
|
|
127
|
+
*/
|
|
128
|
+
export declare function chooseBase(streamRow: StreamRow, head: Head | null, target: number): BaseChoice;
|
|
129
|
+
export type VersionOrder = "higher" | "equal" | "lower";
|
|
130
|
+
export type WriterCompatibility =
|
|
131
|
+
/** Same primary key: merge onto the base. */
|
|
132
|
+
{
|
|
133
|
+
readonly kind: "use_base";
|
|
134
|
+
}
|
|
135
|
+
/** Newer connector changed the primary key: empty base, base complete false. */
|
|
136
|
+
| {
|
|
137
|
+
readonly kind: "empty_base";
|
|
138
|
+
}
|
|
139
|
+
/** Older (or same-version) connector meets a changed key: do not publish. */
|
|
140
|
+
| {
|
|
141
|
+
readonly kind: "connector_update_required";
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* §2.5 compatible-writer rule. `writerVersusBase` is the host's comparison
|
|
145
|
+
* of the writer's connector version with `base.profile.version`.
|
|
146
|
+
*/
|
|
147
|
+
export declare function writerCompatibility(base: Head | null, writerPrimaryKey: readonly string[], writerVersusBase: VersionOrder): WriterCompatibility;
|
|
148
|
+
/**
|
|
149
|
+
* §2.5: `complete` = run `full` OR base `complete` OR seed merged (a host merged its retained history).
|
|
150
|
+
* An empty base (none, tombstoned, other generation, or replaced by
|
|
151
|
+
* `empty_base`) counts as not complete.
|
|
152
|
+
*/
|
|
153
|
+
export declare function headComplete(input: {
|
|
154
|
+
readonly full: boolean;
|
|
155
|
+
readonly baseComplete: boolean;
|
|
156
|
+
readonly seedMerged: boolean;
|
|
157
|
+
}): boolean;
|
|
158
|
+
/** §2.6: every part and `complete` unchanged against the base → skip upload and POST. */
|
|
159
|
+
export declare function isNoop(base: Head | null, parts: readonly HeadPart[], complete: boolean): boolean;
|
|
160
|
+
//# sourceMappingURL=head.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"head.d.ts","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAQL,KAAK,SAAS,EACf,MAAM,YAAY,CAAC;AAIpB,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,eAAO,MAAM,WAAW,IAAI,CAAC;AAE7B,MAAM,WAAW,WAAW;IAC1B,qDAAqD;IACrD,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,iDAAiD;IACjD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;IACnB,qDAAqD;IACrD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAC;IACpC,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAoJD,8EAA8E;AAC9E,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,UAAU,CAiBjD;AAED,MAAM,WAAW,UAAU;IACzB,8CAA8C;IAC9C,QAAQ,CAAC,QAAQ,EAAE,KAAK,MAAM,EAAE,CAAC;IACjC,iBAAiB;IACjB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,wBAAsB,UAAU,CAC9B,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,UAAU,GAChB,OAAO,CAAC,UAAU,CAAC,CAGrB;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;CAAE,GAC5C;IAAE,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GACjE;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAI5D;;;GAGG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CA6CzD;AAED,MAAM,MAAM,YAAY,GACpB;IAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;CAAE,GAC5C;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAAE,GAClC;IAAE,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GACjE;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAAE,GAClC;IAAE,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;CAAE,CAAC;AAEtC;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE;IACR,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B,GACA,OAAO,CAAC,YAAY,CAAC,CAgBvB;AAED,gDAAgD;AAChD,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,YAAY,GAAG,SAAS,CAAC;AAE5D;;;GAGG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,YAAY,EACrB,SAAS,EAAE,SAAS,EACpB,cAAc,EAAE,MAAM,GAAG,IAAI,GAC5B,OAAO,CAMT;AAED,MAAM,MAAM,UAAU;AACpB,wEAAwE;AACtE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;CAAE;AAChD,wEAAwE;GACtE;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE;AAC5B;;;;GAIG;GACD;IAAE,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAA;CAAE,CAAC;AAE1C;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CACxB,SAAS,EAAE,SAAS,EACpB,IAAI,EAAE,IAAI,GAAG,IAAI,EACjB,MAAM,EAAE,MAAM,GACb,UAAU,CAUZ;AAED,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;AAExD,MAAM,MAAM,mBAAmB;AAC7B,6CAA6C;AAC3C;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;CAAE;AAC/B,gFAAgF;GAC9E;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAA;CAAE;AACjC,6EAA6E;GAC3E;IAAE,QAAQ,CAAC,IAAI,EAAE,2BAA2B,CAAA;CAAE,CAAC;AAEnD;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,IAAI,GAAG,IAAI,EACjB,gBAAgB,EAAE,SAAS,MAAM,EAAE,EACnC,gBAAgB,EAAE,YAAY,GAC7B,mBAAmB,CASrB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE;IAClC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC9B,GAAG,OAAO,CAEV;AAED,yFAAyF;AACzF,wBAAgB,MAAM,CACpB,IAAI,EAAE,IAAI,GAAG,IAAI,EACjB,KAAK,EAAE,SAAS,QAAQ,EAAE,EAC1B,QAAQ,EAAE,OAAO,GAChB,OAAO,CAYT"}
|
package/dist/head.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §2.5 head format 1, and the head rules of §2.4, §2.5, §2.6 and §2.7:
|
|
3
|
+
* liveness, base choice, the compatible-writer rule, `complete` and no-op.
|
|
4
|
+
*
|
|
5
|
+
* Clear head bytes are canonical JSON:
|
|
6
|
+
*
|
|
7
|
+
* {"complete":bool,"connector":c,"format":1,"generation":"<dec>",
|
|
8
|
+
* "keyCheck":"<16 hex>","ns":ns,"observedAt":<ms>,
|
|
9
|
+
* "parts":[{"bytes":n,"count":n,"hash":"<sha256>","prefix":"<bits>"}],
|
|
10
|
+
* "primaryKey":[...],"profile":{"key":<string|null>,"version":"<v>"},
|
|
11
|
+
* "recordCount":n,"stream":s}
|
|
12
|
+
*
|
|
13
|
+
* The registry `dataHash` of a head is Keccak-256 of these bytes; its blob
|
|
14
|
+
* name is `h-<that hash in hex>`.
|
|
15
|
+
*/
|
|
16
|
+
import { HEAD_LIMIT_BYTES, HEX16, HEX64, PART_LIMIT_BYTES, PLANNER_DEPTH_CAP, keccakHex, utf8, } from "./bytes.js";
|
|
17
|
+
import { InvalidArgumentError } from "./errors.js";
|
|
18
|
+
import { canonicalJson, hasOwn, isPlainObject } from "./json.js";
|
|
19
|
+
import { headBlobName } from "./names.js";
|
|
20
|
+
export const HEAD_FORMAT = 1;
|
|
21
|
+
const HEAD_FIELDS = [
|
|
22
|
+
"complete",
|
|
23
|
+
"connector",
|
|
24
|
+
"format",
|
|
25
|
+
"generation",
|
|
26
|
+
"keyCheck",
|
|
27
|
+
"ns",
|
|
28
|
+
"observedAt",
|
|
29
|
+
"parts",
|
|
30
|
+
"primaryKey",
|
|
31
|
+
"profile",
|
|
32
|
+
"recordCount",
|
|
33
|
+
"stream",
|
|
34
|
+
];
|
|
35
|
+
const PART_FIELDS = ["bytes", "count", "hash", "prefix"];
|
|
36
|
+
const PROFILE_FIELDS = ["key", "version"];
|
|
37
|
+
const GENERATION = /^[1-9][0-9]*$/;
|
|
38
|
+
function exactKeys(value, expected) {
|
|
39
|
+
const keys = Object.keys(value).sort();
|
|
40
|
+
return (keys.length === expected.length && keys.every((k, i) => k === expected[i]));
|
|
41
|
+
}
|
|
42
|
+
function isCount(value, min) {
|
|
43
|
+
return (typeof value === "number" && Number.isSafeInteger(value) && value >= min);
|
|
44
|
+
}
|
|
45
|
+
/** Why a head is malformed, or `null` when it is a valid format-1 head. */
|
|
46
|
+
function headProblem(value) {
|
|
47
|
+
if (!exactKeys(value, HEAD_FIELDS)) {
|
|
48
|
+
return `fields must be exactly: ${HEAD_FIELDS.join(", ")}`;
|
|
49
|
+
}
|
|
50
|
+
const v = value;
|
|
51
|
+
if (typeof v["complete"] !== "boolean")
|
|
52
|
+
return "complete must be a boolean";
|
|
53
|
+
const connector = v["connector"];
|
|
54
|
+
if (typeof connector !== "string" ||
|
|
55
|
+
connector === "" ||
|
|
56
|
+
connector.includes("~")) {
|
|
57
|
+
return "connector must be non-empty with no ~";
|
|
58
|
+
}
|
|
59
|
+
const generation = v["generation"];
|
|
60
|
+
if (typeof generation !== "string" ||
|
|
61
|
+
!GENERATION.test(generation) ||
|
|
62
|
+
!Number.isSafeInteger(Number(generation))) {
|
|
63
|
+
return "generation must be a positive decimal string";
|
|
64
|
+
}
|
|
65
|
+
if (typeof v["keyCheck"] !== "string" || !HEX16.test(v["keyCheck"])) {
|
|
66
|
+
return "keyCheck must be 16 lowercase hex";
|
|
67
|
+
}
|
|
68
|
+
const ns = v["ns"];
|
|
69
|
+
if (typeof ns !== "string" || (ns !== "" && !HEX16.test(ns))) {
|
|
70
|
+
return "ns must be empty or 16 lowercase hex";
|
|
71
|
+
}
|
|
72
|
+
if (!isCount(v["observedAt"], 0))
|
|
73
|
+
return "observedAt must be a non-negative integer";
|
|
74
|
+
if (typeof v["stream"] !== "string" || v["stream"] === "") {
|
|
75
|
+
return "stream must be a non-empty string";
|
|
76
|
+
}
|
|
77
|
+
const primaryKey = v["primaryKey"];
|
|
78
|
+
if (!Array.isArray(primaryKey) ||
|
|
79
|
+
primaryKey.length === 0 ||
|
|
80
|
+
primaryKey.some((f) => typeof f !== "string" || f === "") ||
|
|
81
|
+
new Set(primaryKey).size !== primaryKey.length) {
|
|
82
|
+
return "primaryKey must be a non-empty list of distinct non-empty strings";
|
|
83
|
+
}
|
|
84
|
+
const profile = v["profile"];
|
|
85
|
+
if (!isPlainObject(profile) ||
|
|
86
|
+
!exactKeys(profile, PROFILE_FIELDS) ||
|
|
87
|
+
(profile["key"] !== null && typeof profile["key"] !== "string") ||
|
|
88
|
+
typeof profile["version"] !== "string" ||
|
|
89
|
+
profile["version"] === "") {
|
|
90
|
+
return "profile must be {key: string|null, version: non-empty string}";
|
|
91
|
+
}
|
|
92
|
+
if (!isCount(v["recordCount"], 0))
|
|
93
|
+
return "recordCount must be a non-negative integer";
|
|
94
|
+
const parts = v["parts"];
|
|
95
|
+
if (!Array.isArray(parts))
|
|
96
|
+
return "parts must be an array";
|
|
97
|
+
let total = 0;
|
|
98
|
+
let previous = null;
|
|
99
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
100
|
+
const part = parts[i];
|
|
101
|
+
if (!isPlainObject(part) || !exactKeys(part, PART_FIELDS)) {
|
|
102
|
+
return `part ${i} must have exactly ${PART_FIELDS.join(", ")}`;
|
|
103
|
+
}
|
|
104
|
+
const { bytes, count, hash, prefix } = part;
|
|
105
|
+
if (!isCount(count, 1))
|
|
106
|
+
return `part ${i} count must be a positive integer`;
|
|
107
|
+
if (!isCount(bytes, 1) || bytes > PART_LIMIT_BYTES) {
|
|
108
|
+
return `part ${i} bytes must be in [1, ${PART_LIMIT_BYTES}]`;
|
|
109
|
+
}
|
|
110
|
+
if (typeof hash !== "string" || !HEX64.test(hash)) {
|
|
111
|
+
return `part ${i} hash must be 64 lowercase hex`;
|
|
112
|
+
}
|
|
113
|
+
if (typeof prefix !== "string" ||
|
|
114
|
+
!/^[01]*$/.test(prefix) ||
|
|
115
|
+
prefix.length > PLANNER_DEPTH_CAP) {
|
|
116
|
+
return `part ${i} prefix must be at most 16 bits of 0 and 1`;
|
|
117
|
+
}
|
|
118
|
+
if (previous !== null) {
|
|
119
|
+
if (prefix <= previous)
|
|
120
|
+
return "parts must be sorted by prefix";
|
|
121
|
+
if (prefix.startsWith(previous))
|
|
122
|
+
return "part prefixes must be prefix-free";
|
|
123
|
+
}
|
|
124
|
+
previous = prefix;
|
|
125
|
+
total += count;
|
|
126
|
+
}
|
|
127
|
+
if (total !== v["recordCount"])
|
|
128
|
+
return "recordCount must equal the sum of part counts";
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
function toWire(head) {
|
|
132
|
+
return {
|
|
133
|
+
complete: head.complete,
|
|
134
|
+
connector: head.connector,
|
|
135
|
+
format: head.format,
|
|
136
|
+
generation: String(head.generation),
|
|
137
|
+
keyCheck: head.keyCheck,
|
|
138
|
+
ns: head.ns,
|
|
139
|
+
observedAt: head.observedAt,
|
|
140
|
+
parts: head.parts.map((p) => ({
|
|
141
|
+
bytes: p.bytes,
|
|
142
|
+
count: p.count,
|
|
143
|
+
hash: p.hash,
|
|
144
|
+
prefix: p.prefix,
|
|
145
|
+
})),
|
|
146
|
+
primaryKey: [...head.primaryKey],
|
|
147
|
+
profile: { key: head.profile.key, version: head.profile.version },
|
|
148
|
+
recordCount: head.recordCount,
|
|
149
|
+
stream: head.stream,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Canonical clear head bytes. Throws on a head this package would reject. */
|
|
153
|
+
export function encodeHead(head) {
|
|
154
|
+
if (head.format !== HEAD_FORMAT) {
|
|
155
|
+
throw new InvalidArgumentError("only format 1 heads can be written");
|
|
156
|
+
}
|
|
157
|
+
if (!Number.isSafeInteger(head.generation) || head.generation < 1) {
|
|
158
|
+
throw new InvalidArgumentError("generation must be a positive safe integer");
|
|
159
|
+
}
|
|
160
|
+
const wire = toWire(head);
|
|
161
|
+
const problem = headProblem(wire);
|
|
162
|
+
if (problem)
|
|
163
|
+
throw new InvalidArgumentError(`invalid head: ${problem}`);
|
|
164
|
+
const bytes = utf8(canonicalJson(wire));
|
|
165
|
+
if (bytes.length > HEAD_LIMIT_BYTES) {
|
|
166
|
+
throw new InvalidArgumentError("head exceeds the 1 MiB head limit");
|
|
167
|
+
}
|
|
168
|
+
return bytes;
|
|
169
|
+
}
|
|
170
|
+
export async function digestHead(keccak256, bytes) {
|
|
171
|
+
const dataHash = await keccakHex(keccak256, bytes);
|
|
172
|
+
return { dataHash, blobName: headBlobName(dataHash) };
|
|
173
|
+
}
|
|
174
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
175
|
+
/**
|
|
176
|
+
* Parse clear head bytes. Any `format` other than 1 is `unsupported_format`.
|
|
177
|
+
* A format-1 head must be canonical, so every host derives the same hash.
|
|
178
|
+
*/
|
|
179
|
+
export function decodeHead(bytes) {
|
|
180
|
+
if (bytes.length > HEAD_LIMIT_BYTES) {
|
|
181
|
+
return {
|
|
182
|
+
kind: "malformed",
|
|
183
|
+
reason: "head exceeds the 1 MiB decoder limit",
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
let text;
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
text = decoder.decode(bytes);
|
|
190
|
+
parsed = JSON.parse(text);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return { kind: "malformed", reason: "head is not UTF-8 JSON" };
|
|
194
|
+
}
|
|
195
|
+
if (!isPlainObject(parsed))
|
|
196
|
+
return { kind: "malformed", reason: "head is not an object" };
|
|
197
|
+
const format = hasOwn(parsed, "format") ? parsed["format"] : undefined;
|
|
198
|
+
if (format !== HEAD_FORMAT)
|
|
199
|
+
return { kind: "unsupported_format", format };
|
|
200
|
+
const problem = headProblem(parsed);
|
|
201
|
+
if (problem)
|
|
202
|
+
return { kind: "malformed", reason: problem };
|
|
203
|
+
if (canonicalJson(parsed) !== text) {
|
|
204
|
+
return { kind: "malformed", reason: "head is not canonical JSON" };
|
|
205
|
+
}
|
|
206
|
+
const p = parsed;
|
|
207
|
+
return {
|
|
208
|
+
kind: "ok",
|
|
209
|
+
head: {
|
|
210
|
+
complete: p["complete"],
|
|
211
|
+
connector: p["connector"],
|
|
212
|
+
format: HEAD_FORMAT,
|
|
213
|
+
generation: Number(p["generation"]),
|
|
214
|
+
keyCheck: p["keyCheck"],
|
|
215
|
+
ns: p["ns"],
|
|
216
|
+
observedAt: p["observedAt"],
|
|
217
|
+
parts: p.parts,
|
|
218
|
+
primaryKey: p["primaryKey"],
|
|
219
|
+
profile: p.profile,
|
|
220
|
+
recordCount: p["recordCount"],
|
|
221
|
+
stream: p["stream"],
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* §2.5 reader checks on decrypted head bytes: `keccak = dataHash`, format,
|
|
227
|
+
* shape, name fields against the scope, and `keyCheck`. Part SHA-256 is
|
|
228
|
+
* checked per part by `decodePart`.
|
|
229
|
+
*/
|
|
230
|
+
export async function verifyHead(keccak256, bytes, expected) {
|
|
231
|
+
const dataHash = await keccakHex(keccak256, bytes);
|
|
232
|
+
if (dataHash !== expected.dataHash.toLowerCase())
|
|
233
|
+
return { kind: "hash_mismatch" };
|
|
234
|
+
const decoded = decodeHead(bytes);
|
|
235
|
+
if (decoded.kind !== "ok")
|
|
236
|
+
return decoded;
|
|
237
|
+
const { head } = decoded;
|
|
238
|
+
if (head.connector !== expected.connector ||
|
|
239
|
+
head.ns !== expected.ns ||
|
|
240
|
+
head.stream !== expected.stream) {
|
|
241
|
+
return { kind: "name_mismatch" };
|
|
242
|
+
}
|
|
243
|
+
if (head.keyCheck !== expected.keyCheck)
|
|
244
|
+
return { kind: "key_mismatch" };
|
|
245
|
+
return decoded;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* §2.4: a stream head is live iff the control is `active(g)`, the stream row
|
|
249
|
+
* is not tombstoned, and the head's generation is g.
|
|
250
|
+
*/
|
|
251
|
+
export function isHeadLive(control, streamRow, headGeneration) {
|
|
252
|
+
return (control.kind === "active" &&
|
|
253
|
+
streamRow === "present" &&
|
|
254
|
+
headGeneration === control.generation);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* §2.7 with ruling F1: three outcomes, never two.
|
|
258
|
+
*
|
|
259
|
+
* - row absent or tombstoned, or `head.generation < target` → `empty`
|
|
260
|
+
* - `head.generation == target` → `base`
|
|
261
|
+
* - `head.generation > target` → `stale_generation`
|
|
262
|
+
*
|
|
263
|
+
* A present row needs its verified head: a host that cannot read the head of
|
|
264
|
+
* a present row must not call this (it would take an empty base over data).
|
|
265
|
+
*/
|
|
266
|
+
export function chooseBase(streamRow, head, target) {
|
|
267
|
+
if (streamRow !== "present")
|
|
268
|
+
return { kind: "empty" };
|
|
269
|
+
if (head === null) {
|
|
270
|
+
throw new InvalidArgumentError("a present stream row needs its verified head");
|
|
271
|
+
}
|
|
272
|
+
if (head.generation < target)
|
|
273
|
+
return { kind: "empty" };
|
|
274
|
+
if (head.generation === target)
|
|
275
|
+
return { kind: "base", head };
|
|
276
|
+
return { kind: "stale_generation" };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* §2.5 compatible-writer rule. `writerVersusBase` is the host's comparison
|
|
280
|
+
* of the writer's connector version with `base.profile.version`.
|
|
281
|
+
*/
|
|
282
|
+
export function writerCompatibility(base, writerPrimaryKey, writerVersusBase) {
|
|
283
|
+
if (base === null)
|
|
284
|
+
return { kind: "use_base" };
|
|
285
|
+
const same = base.primaryKey.length === writerPrimaryKey.length &&
|
|
286
|
+
base.primaryKey.every((field, i) => field === writerPrimaryKey[i]);
|
|
287
|
+
if (same)
|
|
288
|
+
return { kind: "use_base" };
|
|
289
|
+
return writerVersusBase === "higher"
|
|
290
|
+
? { kind: "empty_base" }
|
|
291
|
+
: { kind: "connector_update_required" };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* §2.5: `complete` = run `full` OR base `complete` OR seed merged (a host merged its retained history).
|
|
295
|
+
* An empty base (none, tombstoned, other generation, or replaced by
|
|
296
|
+
* `empty_base`) counts as not complete.
|
|
297
|
+
*/
|
|
298
|
+
export function headComplete(input) {
|
|
299
|
+
return input.full || input.baseComplete || input.seedMerged;
|
|
300
|
+
}
|
|
301
|
+
/** §2.6: every part and `complete` unchanged against the base → skip upload and POST. */
|
|
302
|
+
export function isNoop(base, parts, complete) {
|
|
303
|
+
if (base === null || base.complete !== complete)
|
|
304
|
+
return false;
|
|
305
|
+
if (base.parts.length !== parts.length)
|
|
306
|
+
return false;
|
|
307
|
+
return base.parts.every((p, i) => {
|
|
308
|
+
const q = parts[i];
|
|
309
|
+
return (p.prefix === q.prefix &&
|
|
310
|
+
p.hash === q.hash &&
|
|
311
|
+
p.bytes === q.bytes &&
|
|
312
|
+
p.count === q.count);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
//# sourceMappingURL=head.js.map
|