@hearthlog/ledger 0.0.0-stage → 0.1.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 +235 -0
- package/README.md +173 -2
- package/contracts/append-hash.json +30 -0
- package/docs/database-structure.md +176 -0
- package/docs/event-store-service-design.md +124 -0
- package/docs/event-store-service.http +182 -0
- package/docs/event-store-service.openapi.yaml +1651 -0
- package/package.json +43 -3
- package/scripts/logical-backup.ts +656 -0
- package/src/action-batches.ts +65 -0
- package/src/app-registry.ts +479 -0
- package/src/auth.ts +186 -0
- package/src/contracts.ts +251 -0
- package/src/domain.ts +245 -0
- package/src/errors.ts +50 -0
- package/src/event-validation.ts +181 -0
- package/src/fence.ts +76 -0
- package/src/http.ts +656 -0
- package/src/index.ts +3 -0
- package/src/ledger-app.ts +2057 -0
- package/src/rpc.ts +42 -0
- package/src/schema.ts +236 -0
- package/src/schemas.ts +429 -0
- package/src/sqlite-export.ts +136 -0
- package/src/transfer.ts +219 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type ErrorValue = string | number | boolean | null;
|
|
2
|
+
export type ErrorBody = Record<string, ErrorValue>;
|
|
3
|
+
|
|
4
|
+
export class LedgerError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
readonly status: number,
|
|
7
|
+
readonly body: ErrorBody,
|
|
8
|
+
) {
|
|
9
|
+
super(
|
|
10
|
+
typeof body.message === "string"
|
|
11
|
+
? body.message
|
|
12
|
+
: typeof body.error === "string"
|
|
13
|
+
? body.error
|
|
14
|
+
: "Ledger request failed",
|
|
15
|
+
);
|
|
16
|
+
this.name = "LedgerError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validationError(message: string): LedgerError {
|
|
21
|
+
return new LedgerError(400, {
|
|
22
|
+
error: "ValidationError",
|
|
23
|
+
code: "validation_error",
|
|
24
|
+
message,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function invalidEvent(message: string): LedgerError {
|
|
29
|
+
return new LedgerError(400, {
|
|
30
|
+
error: "ValidationError",
|
|
31
|
+
code: "invalid_event_envelope",
|
|
32
|
+
message,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function errorResponse(error: LedgerError): Response {
|
|
37
|
+
return Response.json(error.body, { status: error.status });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function jsonError(
|
|
41
|
+
code: string,
|
|
42
|
+
message: string,
|
|
43
|
+
status: number,
|
|
44
|
+
): Response {
|
|
45
|
+
return Response.json({ error: message, code }, { status });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizeError(cause: unknown): Error {
|
|
49
|
+
return cause instanceof Error ? cause : new Error(String(cause));
|
|
50
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { EVENT_OWNERS, EVENT_SCHEMAS, type EventSchema, type PayloadKind } from "#generated/event-schemas";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `report` logs what `enforce` would reject and accepts the append. Rejection
|
|
5
|
+
* turns a client's saved change into a rejected local batch, so the mode
|
|
6
|
+
* switches per deployment: watch the logs in `report`, then enforce.
|
|
7
|
+
*/
|
|
8
|
+
export type EventValidationMode = "report" | "enforce";
|
|
9
|
+
|
|
10
|
+
export interface PayloadViolation {
|
|
11
|
+
/** Zero-based position of the event in the request. */
|
|
12
|
+
index: number;
|
|
13
|
+
type: string;
|
|
14
|
+
/** Names a key or a rule, never a value, so it is safe to log. */
|
|
15
|
+
problem: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const META_TYPES = new Set([
|
|
19
|
+
"ENTITY_TYPE_DEFINED",
|
|
20
|
+
"ENTITY_TYPE_UPDATED",
|
|
21
|
+
"ENTITY_TYPE_ARCHIVED",
|
|
22
|
+
]);
|
|
23
|
+
const DIRECTIVE_TYPES = new Set(["EVENTS_EXCISED", "WORKSPACE_RESTORED"]);
|
|
24
|
+
// Entity types defined at runtime have no schema here: only their shape is checked.
|
|
25
|
+
const DYNAMIC_TYPE = /^[A-Z][A-Z0-9_]{0,95}_(?:ADDED|UPDATED|REMOVED)$/;
|
|
26
|
+
const IDENTIFIER = /^[a-z][a-z0-9_]{0,63}$/;
|
|
27
|
+
const MAX_DYNAMIC_KEYS = 256;
|
|
28
|
+
const MAX_KEY_LENGTH = 128;
|
|
29
|
+
const MAX_DYNAMIC_FIELDS = 200;
|
|
30
|
+
|
|
31
|
+
export function eventValidationMode(value: unknown): EventValidationMode {
|
|
32
|
+
return value === "enforce" ? "enforce" : "report";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What one application may append. Applications created before policies
|
|
37
|
+
* existed have none and keep the Worker's behavior: every contract, runtime
|
|
38
|
+
* entity types, and the deployment's validation mode.
|
|
39
|
+
*/
|
|
40
|
+
export interface EventPolicy {
|
|
41
|
+
/** Modules whose event types are accepted; the core's always are. Null accepts every module. */
|
|
42
|
+
contracts: string[] | null;
|
|
43
|
+
/** Runtime-defined entity types (`ENTITY_TYPE_DEFINED` and their events). */
|
|
44
|
+
dynamicEvents: boolean;
|
|
45
|
+
/** Null follows the deployment's `EVENT_VALIDATION`. */
|
|
46
|
+
validation: EventValidationMode | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What an application created without an explicit policy gets: modules unrestricted, nothing undeclared, violations rejected. */
|
|
50
|
+
export const NEW_APPLICATION_POLICY: EventPolicy = { contracts: null, dynamicEvents: false, validation: "enforce" };
|
|
51
|
+
|
|
52
|
+
export function parseEventPolicy(value: unknown): EventPolicy | null {
|
|
53
|
+
if (typeof value !== "string") return null;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(value) as Partial<EventPolicy>;
|
|
56
|
+
return {
|
|
57
|
+
contracts: Array.isArray(parsed.contracts) ? parsed.contracts.filter((id): id is string => typeof id === "string") : null,
|
|
58
|
+
dynamicEvents: parsed.dynamicEvents !== false,
|
|
59
|
+
validation: parsed.validation === "enforce" || parsed.validation === "report" ? parsed.validation : null,
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The mode an append is checked in: the application's own, else the deployment's. */
|
|
67
|
+
export function effectiveValidationMode(policy: EventPolicy | null, deployment: unknown): EventValidationMode {
|
|
68
|
+
return policy?.validation ?? eventValidationMode(deployment);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The event contracts a Worker is built with: the generated ones, or a test's own. */
|
|
72
|
+
export interface EventContracts {
|
|
73
|
+
schemas: Readonly<Record<string, EventSchema>>;
|
|
74
|
+
owners: Readonly<Record<string, string>>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const GENERATED_CONTRACTS: EventContracts = { schemas: EVENT_SCHEMAS, owners: EVENT_OWNERS };
|
|
78
|
+
|
|
79
|
+
export function findPayloadViolations(
|
|
80
|
+
events: ReadonlyArray<{ type: string; payload: Record<string, unknown> }>,
|
|
81
|
+
limit = 5,
|
|
82
|
+
policy: EventPolicy | null = null,
|
|
83
|
+
contracts: EventContracts = GENERATED_CONTRACTS,
|
|
84
|
+
): PayloadViolation[] {
|
|
85
|
+
const violations: PayloadViolation[] = [];
|
|
86
|
+
for (const [index, event] of events.entries()) {
|
|
87
|
+
for (const problem of eventProblems(event.type, event.payload, policy, contracts)) {
|
|
88
|
+
violations.push({ index, type: event.type, problem });
|
|
89
|
+
if (violations.length >= limit) return violations;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return violations;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function eventProblems(type: string, payload: Record<string, unknown>, policy: EventPolicy | null, contracts: EventContracts): string[] {
|
|
96
|
+
const schema = contracts.schemas[type];
|
|
97
|
+
if (schema) {
|
|
98
|
+
const owner = contracts.owners[type];
|
|
99
|
+
if (policy?.contracts && owner && owner !== "core" && !policy.contracts.includes(owner)) {
|
|
100
|
+
return [`event type is not part of this application's modules (${owner})`];
|
|
101
|
+
}
|
|
102
|
+
const problems = requireId(payload);
|
|
103
|
+
for (const [key, kind] of Object.entries(schema.fields)) {
|
|
104
|
+
const value = payload[key];
|
|
105
|
+
if (value === undefined || value === null) continue;
|
|
106
|
+
if (!matchesKind(value, kind)) problems.push(`${key} must be a ${kind}`);
|
|
107
|
+
}
|
|
108
|
+
if (schema.kind === "added") {
|
|
109
|
+
for (const key of schema.required) {
|
|
110
|
+
if (payload[key] === undefined || payload[key] === null) problems.push(`${key} is required`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return problems;
|
|
114
|
+
}
|
|
115
|
+
if (type === "EVENT_TOMBSTONED") {
|
|
116
|
+
return typeof payload.eventId === "string" && payload.eventId !== ""
|
|
117
|
+
? []
|
|
118
|
+
: ["eventId must be a string"];
|
|
119
|
+
}
|
|
120
|
+
if (DIRECTIVE_TYPES.has(type)) return [];
|
|
121
|
+
if (policy && !policy.dynamicEvents && (META_TYPES.has(type) || DYNAMIC_TYPE.test(type))) {
|
|
122
|
+
return ["unknown event type"];
|
|
123
|
+
}
|
|
124
|
+
if (META_TYPES.has(type)) return metaProblems(type, payload);
|
|
125
|
+
if (DYNAMIC_TYPE.test(type)) return dynamicProblems(payload);
|
|
126
|
+
return ["unknown event type"];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function requireId(payload: Record<string, unknown>): string[] {
|
|
130
|
+
return typeof payload.id === "string" && payload.id !== "" ? [] : ["id must be a string"];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function matchesKind(value: unknown, kind: PayloadKind): boolean {
|
|
134
|
+
switch (kind) {
|
|
135
|
+
case "string":
|
|
136
|
+
return typeof value === "string";
|
|
137
|
+
case "number":
|
|
138
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
139
|
+
case "boolean":
|
|
140
|
+
// SQLite stores booleans as 0 and 1, and older senders wrote them that way.
|
|
141
|
+
return typeof value === "boolean" || value === 0 || value === 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function metaProblems(type: string, payload: Record<string, unknown>): string[] {
|
|
146
|
+
const problems = requireId(payload);
|
|
147
|
+
if (type !== "ENTITY_TYPE_DEFINED") return problems;
|
|
148
|
+
for (const key of ["name", "tableName"] as const) {
|
|
149
|
+
const value = payload[key];
|
|
150
|
+
if (typeof value !== "string" || !IDENTIFIER.test(value)) problems.push(`${key} must be a lowercase identifier`);
|
|
151
|
+
}
|
|
152
|
+
if (typeof payload.displayName !== "string" || payload.displayName === "") {
|
|
153
|
+
problems.push("displayName must be a string");
|
|
154
|
+
}
|
|
155
|
+
const fields = payload.fields;
|
|
156
|
+
if (!Array.isArray(fields)) {
|
|
157
|
+
problems.push("fields must be a list");
|
|
158
|
+
} else if (fields.length > MAX_DYNAMIC_FIELDS) {
|
|
159
|
+
problems.push(`fields has more than ${MAX_DYNAMIC_FIELDS} entries`);
|
|
160
|
+
} else if (fields.some((field) => !isRecord(field) || typeof field.name !== "string" || !IDENTIFIER.test(field.name))) {
|
|
161
|
+
problems.push("every field needs a lowercase identifier name");
|
|
162
|
+
}
|
|
163
|
+
return problems;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function dynamicProblems(payload: Record<string, unknown>): string[] {
|
|
167
|
+
const problems = requireId(payload);
|
|
168
|
+
const keys = Object.keys(payload);
|
|
169
|
+
if (keys.length > MAX_DYNAMIC_KEYS) problems.push(`payload has more than ${MAX_DYNAMIC_KEYS} keys`);
|
|
170
|
+
if (keys.some((key) => key.length > MAX_KEY_LENGTH)) problems.push(`a payload key is longer than ${MAX_KEY_LENGTH} characters`);
|
|
171
|
+
const nested = keys.find((key) => {
|
|
172
|
+
const value = payload[key];
|
|
173
|
+
return value !== null && typeof value === "object";
|
|
174
|
+
});
|
|
175
|
+
if (nested !== undefined) problems.push(`${nested.slice(0, MAX_KEY_LENGTH)} must be a string, number, boolean or null`);
|
|
176
|
+
return problems;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
180
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
181
|
+
}
|
package/src/fence.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write fence of a tenant move.
|
|
3
|
+
*
|
|
4
|
+
* While an application is being exported and imported elsewhere, writes to it
|
|
5
|
+
* must stop, or the copy would miss them. `tenant_migrating` is a pause: the
|
|
6
|
+
* Ledger answers 503 with `Retry-After`, which the app's outbox already treats
|
|
7
|
+
* as transient, so queued work waits and lands exactly once afterwards.
|
|
8
|
+
* `moved` is final: the application lives elsewhere, and the Ledger answers
|
|
9
|
+
* 410 with the destination, so an old client can say so and offer to export
|
|
10
|
+
* its pending work. Reads stay open in both modes.
|
|
11
|
+
*
|
|
12
|
+
* The fence is enforced here on the server; a banner in the frontend cannot
|
|
13
|
+
* stop an old cached client.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { LedgerError, validationError } from "./errors";
|
|
17
|
+
|
|
18
|
+
export type FenceMode = "tenant_migrating" | "moved";
|
|
19
|
+
|
|
20
|
+
export interface Fence {
|
|
21
|
+
mode: FenceMode;
|
|
22
|
+
/** Where the application lives now; only for `moved`. */
|
|
23
|
+
destination: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What an administrator sets: `off` lifts the fence. */
|
|
27
|
+
export interface FenceRequest {
|
|
28
|
+
mode: FenceMode | "off";
|
|
29
|
+
destination?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const RETRY_AFTER_SECONDS = 30;
|
|
33
|
+
|
|
34
|
+
export function parseFence(mode: unknown, destination: unknown): Fence | null {
|
|
35
|
+
if (mode !== "tenant_migrating" && mode !== "moved") return null;
|
|
36
|
+
return { mode, destination: typeof destination === "string" && destination ? destination : null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateFenceRequest(request: FenceRequest): Fence | null {
|
|
40
|
+
if (request.mode === "off") {
|
|
41
|
+
if (request.destination) throw validationError("destination only goes with the moved mode");
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (request.mode === "tenant_migrating") {
|
|
45
|
+
if (request.destination) throw validationError("destination only goes with the moved mode");
|
|
46
|
+
return { mode: "tenant_migrating", destination: null };
|
|
47
|
+
}
|
|
48
|
+
let url: URL;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(String(request.destination));
|
|
51
|
+
} catch {
|
|
52
|
+
throw validationError("moved needs the destination origin");
|
|
53
|
+
}
|
|
54
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
|
|
55
|
+
throw validationError("destination must be an HTTPS origin");
|
|
56
|
+
}
|
|
57
|
+
return { mode: "moved", destination: url.origin };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The error a fenced write gets. */
|
|
61
|
+
export function fenceError(fence: Fence): LedgerError {
|
|
62
|
+
if (fence.mode === "tenant_migrating") {
|
|
63
|
+
return new LedgerError(503, {
|
|
64
|
+
error: "Unavailable",
|
|
65
|
+
code: "tenant_migrating",
|
|
66
|
+
message: "This workspace is being moved; writes resume shortly",
|
|
67
|
+
retryAfterSeconds: RETRY_AFTER_SECONDS,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return new LedgerError(410, {
|
|
71
|
+
error: "Gone",
|
|
72
|
+
code: "tenant_moved",
|
|
73
|
+
message: "This workspace has moved",
|
|
74
|
+
...(fence.destination ? { destination: fence.destination } : {}),
|
|
75
|
+
});
|
|
76
|
+
}
|