@sekiban/dcb-core 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 ADDED
@@ -0,0 +1,86 @@
1
+ Elastic License 2.0
2
+
3
+ Acceptance
4
+
5
+ By using the software, you agree to all of the terms and conditions below.
6
+
7
+ Copyright License
8
+
9
+ The licensor grants you a license, under any patent claims the licensor can
10
+ license, or becomes able to license, to make, have made, use, sell, offer for
11
+ sale, import and have imported the software, in each case subject to the
12
+ limitations and conditions in this license. This license does not cover any
13
+ patent claims that you cause to be infringed by modifications or additions to
14
+ the software.
15
+
16
+ Limitations
17
+
18
+ You may not provide the software to third parties as a hosted or managed
19
+ service, where the service provides users with access to any substantial set
20
+ of the features or functionality of the software.
21
+
22
+ You may not move, change, disable, or circumvent the license key functionality
23
+ in the software, and you may not remove or obscure any functionality in the
24
+ software that is protected by the license key.
25
+
26
+ You may not alter, remove, or obscure any licensing, copyright, or other
27
+ notices of the licensor in the software. Any use of the licensor's trademarks
28
+ is subject to applicable law.
29
+
30
+ If you or your company make any written claim that the software infringes or
31
+ contributes to infringement of any patent, your patent license for the
32
+ software granted under these terms ends immediately. If your company makes
33
+ such a claim, your patent license ends immediately for work on behalf of your
34
+ company.
35
+
36
+ Notices
37
+
38
+ You must ensure that anyone who gets a copy of any part of the software from
39
+ you also gets a copy of these terms.
40
+
41
+ If you modify the software, you must include in any modified copies of the
42
+ software prominent notices stating that you have modified the software.
43
+
44
+ No Other Rights
45
+
46
+ These terms do not imply any licenses other than those expressly granted in
47
+ this license.
48
+
49
+ Termination
50
+
51
+ If you use the software in violation of this license, such use is not
52
+ licensed, and your licenses will automatically terminate. If the licensor
53
+ provides you with a notice of your violation, and you cease all violation of
54
+ this license no later than 30 days after you receive that notice, your
55
+ licenses will be reinstated retroactively. However, if you violate this
56
+ license after reinstatement, any additional violation will cause your licenses
57
+ to terminate automatically and permanently.
58
+
59
+ No Liability
60
+
61
+ As far as the law allows, the software comes as is, without any warranty or
62
+ condition, and the licensor will not be liable to you for any damages arising
63
+ out of the terms or use of the software, under any kind of legal claim.
64
+
65
+ Definitions
66
+
67
+ The licensor is the entity offering these terms, and the software is the
68
+ software the licensor makes available under these terms, including any part
69
+ of it.
70
+
71
+ you refers to the individual or entity agreeing to these terms.
72
+
73
+ your company is any legal entity, sole proprietorship, or other kind of
74
+ organization that you work for, plus all organizations that have control over,
75
+ are under the control of, or are under common control with that organization.
76
+
77
+ control means ownership of substantially all the assets of an entity, or the
78
+ power to direct its management and policies by vote, contract, or otherwise.
79
+ Control can be direct or indirect.
80
+
81
+ your licenses are all the licenses granted to you for the software under
82
+ these terms.
83
+
84
+ use means anything you do with the software requiring one of your licenses.
85
+
86
+ trademark means trademarks, service marks, and similar rights.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # @sekiban/dcb-core
2
+
3
+ Cloudflare-independent Serialized DCB definitions and domain algebra.
4
+
5
+ This package contains the portable core types used by the Serialized DCB V1
6
+ domain and client packages. It is published as an ESM package with the public
7
+ surface available from `@sekiban/dcb-core`.
8
+
9
+ It is not the earlier, unrelated `@sekiban/core` package line.
10
+
11
+ ## License
12
+
13
+ Elastic License 2.0. See [LICENSE](./LICENSE).
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Cloudflare-independent building blocks for Serialized DCB V1.
3
+ *
4
+ * This package intentionally contains no fetch, storage, queue, or Workers
5
+ * types. The runtime and client packages depend on these definitions rather
6
+ * than the other way around.
7
+ */
8
+ export type JsonPrimitive = null | boolean | number | string;
9
+ export type JsonValue = JsonPrimitive | JsonValue[] | {
10
+ readonly [key: string]: JsonValue;
11
+ };
12
+ export type JsonBoundary = "event-construction" | "state-persistence" | "command-input" | "value";
13
+ export declare class JsonValidationError extends Error {
14
+ readonly code: "invalid_json_value";
15
+ readonly boundary: JsonBoundary;
16
+ readonly path: string;
17
+ constructor(message: string, boundary?: JsonBoundary, path?: string, options?: ErrorOptions);
18
+ }
19
+ /** Validate and return a JSON value, rejecting non-finite values and cycles. */
20
+ export declare function assertJsonValue(value: unknown, boundary?: JsonBoundary): JsonValue;
21
+ export declare const validateJsonValue: typeof assertJsonValue;
22
+ export declare class DcbDefinitionError extends Error {
23
+ readonly code: string;
24
+ constructor(code: string, message: string, options?: ErrorOptions);
25
+ }
26
+ /** The event identity carried by the post-G27 internal delivery lanes. */
27
+ export interface CanonicalEventIdentity {
28
+ readonly eventPayloadName: string;
29
+ readonly key: string;
30
+ }
31
+ export declare class CanonicalEventIdentityError extends DcbDefinitionError {
32
+ readonly code: "CANONICAL_EVENT_IDENTITY_INVALID";
33
+ constructor(message: string);
34
+ }
35
+ /** Build the only accepted durable identity spelling: eventPayloadName. */
36
+ export declare function canonicalEventKey(eventPayloadName: string): string;
37
+ /** Parse the durable eventPayloadName without consulting payload bytes. */
38
+ export declare function parseCanonicalEventKey(key: string): CanonicalEventIdentity;
39
+ export interface TagDefinition {
40
+ readonly id: string;
41
+ readonly group: string;
42
+ readonly content: string;
43
+ readonly tag: string;
44
+ }
45
+ export type TagInput = TagDefinition | {
46
+ readonly id?: string;
47
+ readonly tag?: string;
48
+ readonly group?: string;
49
+ readonly content?: string;
50
+ } | string;
51
+ export declare function defineTag(group: string, content: string): TagDefinition;
52
+ export declare function defineTag(input: TagInput): TagDefinition;
53
+ export interface DefinedEvent<TPayload extends JsonValue = JsonValue> {
54
+ readonly eventName: string;
55
+ readonly eventPayloadName: string;
56
+ readonly payload: TPayload;
57
+ }
58
+ export interface EventDefinition<TPayload extends JsonValue = JsonValue> {
59
+ readonly name: string;
60
+ readonly eventName: string;
61
+ readonly eventPayloadName: string;
62
+ readonly eventType: string;
63
+ readonly create: (payload: unknown) => DefinedEvent<TPayload>;
64
+ readonly construct: (payload: unknown) => DefinedEvent<TPayload>;
65
+ readonly parse: (payload: unknown) => TPayload;
66
+ }
67
+ export type EventParser<TPayload extends JsonValue> = (payload: unknown) => TPayload;
68
+ export type EventDefinitionOptions<TPayload extends JsonValue> = {
69
+ readonly name?: string;
70
+ readonly eventName?: string;
71
+ readonly eventPayloadName?: string;
72
+ /** Removed by G32: new payload revisions use a new eventPayloadName. */
73
+ readonly version?: never;
74
+ readonly parse?: EventParser<TPayload>;
75
+ readonly parser?: EventParser<TPayload>;
76
+ readonly validate?: EventParser<TPayload>;
77
+ };
78
+ export declare function defineEvent<TPayload extends JsonValue = JsonValue>(name: string, parser?: EventParser<TPayload>): EventDefinition<TPayload>;
79
+ export declare function defineEvent<TPayload extends JsonValue = JsonValue>(options: EventDefinitionOptions<TPayload>): EventDefinition<TPayload>;
80
+ export type ProjectorState = JsonValue;
81
+ export type ProjectorEvent = DefinedEvent | {
82
+ readonly eventName?: string;
83
+ readonly eventPayloadName?: string;
84
+ readonly eventType?: string;
85
+ readonly payload?: unknown;
86
+ };
87
+ export type ProjectorHandler<TState extends JsonValue = JsonValue> = (state: TState, event: DefinedEvent) => TState;
88
+ export interface ProjectorDefinition<TState extends JsonValue = JsonValue> {
89
+ readonly id: string;
90
+ readonly projectorId: string;
91
+ readonly version: number;
92
+ readonly projectorVersion: number;
93
+ readonly subscribedEventNames: readonly string[];
94
+ readonly subscribedEventTypes: readonly string[];
95
+ readonly initialState: TState;
96
+ readonly apply: (state: TState, event: ProjectorEvent) => TState;
97
+ readonly reduce: (state: TState, event: ProjectorEvent) => TState;
98
+ readonly serializeState: (state: TState) => string;
99
+ readonly deserializeState: (serialized: string) => TState;
100
+ }
101
+ export type ProjectorDefinitionOptions<TState extends JsonValue = JsonValue> = {
102
+ readonly id?: string;
103
+ readonly projectorId?: string;
104
+ readonly version?: number;
105
+ readonly projectorVersion?: number;
106
+ readonly subscribedEventNames?: readonly string[];
107
+ readonly subscriptions?: readonly string[];
108
+ readonly events?: readonly (string | EventDefinition)[];
109
+ readonly subscribedEventTypes?: readonly string[];
110
+ readonly initialState: TState;
111
+ readonly handlers?: Readonly<Record<string, ProjectorHandler<TState>>>;
112
+ readonly eventHandlers?: Readonly<Record<string, ProjectorHandler<TState>>>;
113
+ /** Optional canonical-key handlers; name handlers remain valid for v1. */
114
+ readonly eventTypeHandlers?: Readonly<Record<string, ProjectorHandler<TState>>>;
115
+ readonly serializeState?: (state: TState) => string;
116
+ readonly deserializeState?: (serialized: string) => TState;
117
+ };
118
+ export declare function defineProjector<TState extends JsonValue = JsonValue>(options: ProjectorDefinitionOptions<TState>): ProjectorDefinition<TState>;
119
+ export interface AppendedEvent {
120
+ readonly event: EventDefinition;
121
+ readonly payload: JsonValue;
122
+ readonly tags: readonly TagDefinition[];
123
+ }
124
+ export interface CommandContext<TState extends JsonValue = JsonValue> {
125
+ readonly state: <T extends JsonValue = JsonValue>(tag: TagInput) => T | undefined;
126
+ readonly assertEmpty: (tag: TagInput) => void;
127
+ readonly append: (event: EventDefinition, payload: unknown, tags?: readonly TagInput[]) => AppendedEvent;
128
+ readonly done: (value?: JsonValue) => CommandDone<TState>;
129
+ readonly noop: (reason?: string) => Omit<CommandNoop, "events">;
130
+ readonly reject: (reason: string, code?: string) => Omit<CommandRejected, "events">;
131
+ readonly appendedEvents: readonly AppendedEvent[];
132
+ }
133
+ export interface CommandCommitted<TState extends JsonValue = JsonValue> {
134
+ readonly kind: "committed";
135
+ readonly value?: JsonValue;
136
+ readonly state?: TState;
137
+ readonly events: readonly AppendedEvent[];
138
+ }
139
+ export interface CommandDone<TState extends JsonValue = JsonValue> {
140
+ readonly kind: "committed";
141
+ readonly value?: JsonValue;
142
+ readonly state?: TState;
143
+ }
144
+ export interface CommandNoop {
145
+ readonly kind: "noop";
146
+ readonly reason?: string;
147
+ readonly events: readonly [];
148
+ }
149
+ export interface CommandRejected {
150
+ readonly kind: "rejected";
151
+ readonly reason: string;
152
+ readonly code: string;
153
+ readonly events: readonly [];
154
+ }
155
+ export type CommandHandlerOutcome<TState extends JsonValue = JsonValue> = CommandDone<TState> | Omit<CommandNoop, "events"> | Omit<CommandRejected, "events">;
156
+ export type CommandOutcome<TState extends JsonValue = JsonValue> = CommandCommitted<TState> | CommandNoop | CommandRejected;
157
+ export declare const done: <TState extends JsonValue = JsonValue>(value?: JsonValue, state?: TState) => CommandDone<TState>;
158
+ export declare const noop: (reason?: string) => CommandNoop;
159
+ export declare const reject: (reason: string, code?: string) => CommandRejected;
160
+ export type CommandInputParser<TInput> = (input: unknown) => TInput;
161
+ export type CommandHandler<TInput, TState extends JsonValue = JsonValue> = (input: TInput, context: CommandContext<TState>) => CommandHandlerOutcome<TState>;
162
+ export interface CommandDefinition<TInput = unknown, TState extends JsonValue = JsonValue> {
163
+ readonly id: string;
164
+ readonly name: string;
165
+ readonly parseInput: (input: unknown) => TInput;
166
+ readonly execute: (input: unknown, options?: {
167
+ readonly state?: Readonly<Record<string, JsonValue>>;
168
+ }) => CommandOutcome<TState>;
169
+ readonly handle: CommandDefinition<TInput, TState>["execute"];
170
+ }
171
+ export type CommandDefinitionOptions<TInput, TState extends JsonValue = JsonValue> = {
172
+ readonly id?: string;
173
+ readonly name?: string;
174
+ readonly parseInput?: CommandInputParser<TInput>;
175
+ readonly inputParser?: CommandInputParser<TInput>;
176
+ readonly input?: CommandInputParser<TInput>;
177
+ readonly handler: CommandHandler<TInput, TState>;
178
+ };
179
+ export declare function defineCommand<TInput, TState extends JsonValue = JsonValue>(options: CommandDefinitionOptions<TInput, TState>): CommandDefinition<TInput, TState>;
180
+ export interface DomainComponentDefinition {
181
+ readonly id?: string;
182
+ readonly name?: string;
183
+ readonly version?: number;
184
+ readonly projectorVersion?: number;
185
+ readonly queryVersion?: number;
186
+ }
187
+ export interface DomainCollision {
188
+ readonly kind: "event-name" | "command-id" | "projector-id" | "query-id" | "materialized-view-id" | "version-pair";
189
+ readonly id: string;
190
+ readonly version?: number;
191
+ readonly indexes: readonly number[];
192
+ }
193
+ export declare class DomainDefinitionError extends DcbDefinitionError {
194
+ readonly code: "DOMAIN_DEFINITION_INVALID";
195
+ readonly collisions: readonly DomainCollision[];
196
+ constructor(collisions: readonly DomainCollision[]);
197
+ }
198
+ export interface DomainDefinition {
199
+ readonly events: readonly EventDefinition[];
200
+ readonly commands: readonly CommandDefinition[];
201
+ readonly projectors: readonly DomainProjectorDefinition[];
202
+ readonly queries: readonly DomainComponentDefinition[];
203
+ readonly materializedViews: readonly DomainComponentDefinition[];
204
+ readonly eventByName: ReadonlyMap<string, EventDefinition>;
205
+ }
206
+ /** The identity portion needed when registering a projector in a domain. */
207
+ export interface DomainProjectorDefinition {
208
+ readonly id: string;
209
+ readonly version: number;
210
+ }
211
+ export type DomainDefinitionOptions = {
212
+ readonly events?: readonly EventDefinition[];
213
+ readonly commands?: readonly CommandDefinition[];
214
+ readonly projectors?: readonly DomainProjectorDefinition[];
215
+ readonly queries?: readonly DomainComponentDefinition[];
216
+ readonly materializedViews?: readonly DomainComponentDefinition[];
217
+ readonly mvs?: readonly DomainComponentDefinition[];
218
+ };
219
+ export declare function defineDomain(options: DomainDefinitionOptions): DomainDefinition;
220
+ export * from "./materializedView.js";
package/dist/index.js ADDED
@@ -0,0 +1,490 @@
1
+ // src/materializedView.ts
2
+ function nonEmpty(value, code, message) {
3
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${code}: ${message}`);
4
+ }
5
+ function descriptorValue(descriptor, value) {
6
+ if (descriptor.valueType === "text") {
7
+ if (typeof value !== "string") throw new Error(`MV index ${descriptor.id} expected a text value`);
8
+ return value;
9
+ }
10
+ if (typeof value !== "number" || !Number.isFinite(value)) {
11
+ throw new Error(`MV index ${descriptor.id} expected a finite numeric value`);
12
+ }
13
+ if (descriptor.valueType === "integer" && (!Number.isSafeInteger(value) || !Number.isInteger(value))) {
14
+ throw new Error(`MV index ${descriptor.id} expected a safe integer value`);
15
+ }
16
+ return value;
17
+ }
18
+ function validateDescriptors(descriptors) {
19
+ const seen = /* @__PURE__ */ new Set();
20
+ for (const descriptor of descriptors) {
21
+ nonEmpty(descriptor.id, "MV_INDEX_ID_REQUIRED", "Index descriptor id is required");
22
+ if (seen.has(descriptor.id)) throw new Error(`MV_INDEX_DUPLICATE: ${descriptor.id}`);
23
+ seen.add(descriptor.id);
24
+ if (!["text", "integer", "real"].includes(descriptor.valueType)) {
25
+ throw new Error(`MV_INDEX_VALUE_TYPE_INVALID: ${descriptor.id}`);
26
+ }
27
+ if (typeof descriptor.value !== "function") throw new Error(`MV_INDEX_VALUE_REQUIRED: ${descriptor.id}`);
28
+ }
29
+ return Object.freeze(descriptors.map((descriptor) => Object.freeze({ ...descriptor })));
30
+ }
31
+ function normalizeRowInput(input, event) {
32
+ nonEmpty(input.rowKey, "MV_ROW_KEY_REQUIRED", "Row key is required");
33
+ const value = assertJsonValue(input.value, "state-persistence");
34
+ const rowVersion = input.rowVersion ?? 1;
35
+ if (!Number.isSafeInteger(rowVersion) || rowVersion < 0) throw new Error(`MV_ROW_VERSION_INVALID: ${input.rowKey}`);
36
+ const sourceSuid = input.sourceSuid ?? (typeof event === "object" && event !== null && "suid" in event && typeof event.suid === "string" ? event.suid : "");
37
+ nonEmpty(sourceSuid, "MV_SOURCE_SUID_REQUIRED", `Source SUID is required for ${input.rowKey}`);
38
+ return Object.freeze({ rowKey: input.rowKey, value, rowVersion, sourceSuid });
39
+ }
40
+ function normalizeRowDeletes(values = []) {
41
+ const keys = /* @__PURE__ */ new Set();
42
+ for (const value of values) {
43
+ const rowKey = typeof value === "string" ? value : value.rowKey;
44
+ nonEmpty(rowKey, "MV_ROW_KEY_REQUIRED", "Row delete key is required");
45
+ keys.add(rowKey);
46
+ }
47
+ return Object.freeze([...keys].map((rowKey) => Object.freeze({ rowKey })));
48
+ }
49
+ function normalizeIndexEntries(values, descriptors, rowKey) {
50
+ const descriptorById = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
51
+ return Object.freeze(values.map((entry) => {
52
+ nonEmpty(entry.indexId, "MV_INDEX_ID_REQUIRED", "Index entry id is required");
53
+ const descriptor = descriptorById.get(entry.indexId);
54
+ if (descriptor === void 0) throw new Error(`MV_INDEX_UNDECLARED: ${entry.indexId}`);
55
+ nonEmpty(entry.rowKey, "MV_ROW_KEY_REQUIRED", "Index entry row key is required");
56
+ if (rowKey !== void 0 && entry.rowKey !== rowKey) {
57
+ throw new Error(`MV_INDEX_ROW_KEY_MISMATCH: ${entry.indexId}`);
58
+ }
59
+ const valueType = entry.valueType ?? descriptor.valueType;
60
+ if (valueType !== descriptor.valueType) throw new Error(`MV_INDEX_VALUE_TYPE_MISMATCH: ${entry.indexId}`);
61
+ return Object.freeze({
62
+ indexId: entry.indexId,
63
+ valueType,
64
+ value: descriptorValue(descriptor, entry.value),
65
+ rowKey: entry.rowKey
66
+ });
67
+ }));
68
+ }
69
+ function normalizeRowPatches(values, descriptors, event) {
70
+ return Object.freeze(values.map((input) => {
71
+ nonEmpty(input.rowKey, "MV_ROW_KEY_REQUIRED", "Row patch key is required");
72
+ if (input.kind !== "json_patch") throw new Error(`MV_PATCH_KIND_INVALID: ${input.rowKey}`);
73
+ const patch = assertJsonValue(input.patch, "state-persistence");
74
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
75
+ throw new Error(`MV_PATCH_OBJECT_REQUIRED: ${input.rowKey}`);
76
+ }
77
+ const rowVersion = input.rowVersion ?? 1;
78
+ if (!Number.isSafeInteger(rowVersion) || rowVersion < 0) throw new Error(`MV_ROW_VERSION_INVALID: ${input.rowKey}`);
79
+ const sourceSuid = input.sourceSuid ?? (typeof event === "object" && event !== null && "suid" in event && typeof event.suid === "string" ? event.suid : "");
80
+ nonEmpty(sourceSuid, "MV_SOURCE_SUID_REQUIRED", `Source SUID is required for ${input.rowKey}`);
81
+ return Object.freeze({
82
+ kind: "json_patch",
83
+ rowKey: input.rowKey,
84
+ patch,
85
+ rowVersion,
86
+ sourceSuid,
87
+ indexEntries: normalizeIndexEntries(input.indexEntries ?? [], descriptors, input.rowKey)
88
+ });
89
+ }));
90
+ }
91
+ function normalizePlan(result, descriptors, event) {
92
+ const value = result;
93
+ const rawUpserts = value.rowUpserts ?? value.upserts ?? [];
94
+ const rawDeletes = value.rowDeletes ?? value.deletes ?? [];
95
+ const rawPatches = value.rowPatches ?? value.patches ?? [];
96
+ const rowUpserts = rawUpserts.map((row) => normalizeRowInput(row, event));
97
+ const rowDeletes = normalizeRowDeletes(rawDeletes);
98
+ const rowPatches = normalizeRowPatches(rawPatches, descriptors, event);
99
+ const descriptorById = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
100
+ const explicitEntries = value.indexEntries ?? [];
101
+ const generatedEntries = [];
102
+ for (const row of rowUpserts) {
103
+ for (const descriptor of descriptors) {
104
+ let candidate;
105
+ try {
106
+ candidate = descriptor.value(row.value, event);
107
+ } catch (error) {
108
+ throw new Error(`MV_INDEX_VALUE_FAILED: ${descriptor.id}: ${String(error)}`);
109
+ }
110
+ if (candidate === void 0 || candidate === null) continue;
111
+ generatedEntries.push(Object.freeze({
112
+ indexId: descriptor.id,
113
+ valueType: descriptor.valueType,
114
+ value: descriptorValue(descriptor, candidate),
115
+ rowKey: row.rowKey
116
+ }));
117
+ }
118
+ }
119
+ generatedEntries.push(...normalizeIndexEntries(explicitEntries, descriptors));
120
+ const indexDeletes = Object.freeze((value.indexDeletes ?? []).map((entry) => {
121
+ nonEmpty(entry.rowKey, "MV_ROW_KEY_REQUIRED", "Index delete row key is required");
122
+ if (entry.indexId !== void 0) {
123
+ nonEmpty(entry.indexId, "MV_INDEX_ID_REQUIRED", "Index delete id is required");
124
+ if (!descriptorById.has(entry.indexId)) throw new Error(`MV_INDEX_UNDECLARED: ${entry.indexId}`);
125
+ }
126
+ return Object.freeze({ indexId: entry.indexId, rowKey: entry.rowKey });
127
+ }));
128
+ return Object.freeze({
129
+ rowUpserts: Object.freeze(rowUpserts),
130
+ rowDeletes,
131
+ rowPatches,
132
+ indexEntries: Object.freeze(generatedEntries),
133
+ indexDeletes
134
+ });
135
+ }
136
+ function defineRowMaterializer(options) {
137
+ nonEmpty(options.id, "MV_ID_REQUIRED", "Materialized-view id is required");
138
+ const version = options.version ?? 1;
139
+ if (!Number.isSafeInteger(version) || version < 1) throw new Error("MV_VERSION_INVALID: version must be positive");
140
+ const descriptors = validateDescriptors(options.indexDescriptors ?? options.indexes ?? []);
141
+ const materialize = options.materialize ?? options.plan;
142
+ if (typeof materialize !== "function") throw new Error(`MV_MATERIALIZER_REQUIRED: ${options.id}`);
143
+ const plan = (event) => normalizePlan(materialize(event), descriptors, event);
144
+ return Object.freeze({
145
+ id: options.id,
146
+ version,
147
+ indexDescriptors: descriptors,
148
+ plan,
149
+ planFor: plan,
150
+ mutationPlan: plan
151
+ });
152
+ }
153
+ var defineMaterializedView = defineRowMaterializer;
154
+ var defineMaterializedViewRowMaterializer = defineRowMaterializer;
155
+
156
+ // src/index.ts
157
+ var JsonValidationError = class extends Error {
158
+ code = "invalid_json_value";
159
+ boundary;
160
+ path;
161
+ constructor(message, boundary = "value", path = "$", options) {
162
+ super(message, options);
163
+ this.name = "JsonValidationError";
164
+ this.boundary = boundary;
165
+ this.path = path;
166
+ }
167
+ };
168
+ var isPlainRecord = (value) => {
169
+ const prototype = Object.getPrototypeOf(value);
170
+ return prototype === Object.prototype || prototype === null;
171
+ };
172
+ function assertJsonValue(value, boundary = "value") {
173
+ const active = /* @__PURE__ */ new WeakSet();
174
+ const visit = (candidate, path) => {
175
+ if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") {
176
+ return candidate;
177
+ }
178
+ if (typeof candidate === "number") {
179
+ if (Number.isFinite(candidate)) return candidate;
180
+ throw new JsonValidationError("JSON numbers must be finite", boundary, path);
181
+ }
182
+ if (typeof candidate !== "object") {
183
+ throw new JsonValidationError(`Value at ${path} is not JSON serializable`, boundary, path);
184
+ }
185
+ if (active.has(candidate)) {
186
+ throw new JsonValidationError(`Cyclic JSON value at ${path}`, boundary, path);
187
+ }
188
+ active.add(candidate);
189
+ try {
190
+ if (Array.isArray(candidate)) {
191
+ return candidate.map((item, index) => visit(item, `${path}[${index}]`));
192
+ }
193
+ if (!isPlainRecord(candidate)) {
194
+ throw new JsonValidationError(`Value at ${path} must be a plain JSON object`, boundary, path);
195
+ }
196
+ const result = {};
197
+ for (const [key, item] of Object.entries(candidate)) {
198
+ result[key] = visit(item, `${path}.${key}`);
199
+ }
200
+ return result;
201
+ } finally {
202
+ active.delete(candidate);
203
+ }
204
+ };
205
+ return visit(value, "$");
206
+ }
207
+ var validateJsonValue = assertJsonValue;
208
+ var DcbDefinitionError = class extends Error {
209
+ code;
210
+ constructor(code, message, options) {
211
+ super(message, options);
212
+ this.name = "DcbDefinitionError";
213
+ this.code = code;
214
+ }
215
+ };
216
+ var CanonicalEventIdentityError = class extends DcbDefinitionError {
217
+ code = "CANONICAL_EVENT_IDENTITY_INVALID";
218
+ constructor(message) {
219
+ super("CANONICAL_EVENT_IDENTITY_INVALID", message);
220
+ this.name = "CanonicalEventIdentityError";
221
+ }
222
+ };
223
+ function canonicalEventKey(eventPayloadName) {
224
+ if (typeof eventPayloadName !== "string" || eventPayloadName.length === 0 || eventPayloadName.includes(":")) {
225
+ throw new CanonicalEventIdentityError("Event payload names must be non-empty and must not contain ':'");
226
+ }
227
+ return eventPayloadName;
228
+ }
229
+ function parseCanonicalEventKey(key) {
230
+ const canonical = canonicalEventKey(key);
231
+ if (canonical !== key) {
232
+ throw new CanonicalEventIdentityError("Event identity key is not canonical");
233
+ }
234
+ return Object.freeze({ eventPayloadName: key, key });
235
+ }
236
+ var tagParts = (input, content) => {
237
+ if (typeof input === "string") {
238
+ if (content !== void 0) return { group: input, content };
239
+ const separator = input.indexOf(":");
240
+ if (separator > 0) return { group: input.slice(0, separator), content: input.slice(separator + 1), id: input };
241
+ throw new DcbDefinitionError("TAG_INVALID", "A tag string must be group:content");
242
+ }
243
+ const group = input.group;
244
+ const tagContent = input.content;
245
+ const id = input.id ?? input.tag;
246
+ if (typeof group === "string" && typeof tagContent === "string") return { group, content: tagContent, id };
247
+ if (typeof id === "string") {
248
+ const separator = id.indexOf(":");
249
+ if (separator > 0) return { group: id.slice(0, separator), content: id.slice(separator + 1), id };
250
+ }
251
+ throw new DcbDefinitionError("TAG_INVALID", "A tag requires group and content");
252
+ };
253
+ function defineTag(input, content) {
254
+ const parts = tagParts(input, content);
255
+ const id = parts.id ?? `${parts.group}:${parts.content}`;
256
+ if (parts.group.length === 0 || parts.content.length === 0 || id.length === 0) {
257
+ throw new DcbDefinitionError("TAG_INVALID", "Tag group, content, and id must be non-empty");
258
+ }
259
+ return Object.freeze({ id, group: parts.group, content: parts.content, tag: id });
260
+ }
261
+ function defineEvent(input, parser) {
262
+ const options = typeof input === "string" ? { name: input, parse: parser } : input;
263
+ const name = options.name ?? options.eventName;
264
+ if (!name) throw new DcbDefinitionError("EVENT_NAME_REQUIRED", "Event name is required");
265
+ const eventPayloadName = options.eventPayloadName ?? name;
266
+ if (Object.prototype.hasOwnProperty.call(options, "version")) {
267
+ throw new DcbDefinitionError("EVENT_VERSION_REMOVED", "Event version is removed; use a distinct event payload name");
268
+ }
269
+ const eventType = canonicalEventKey(eventPayloadName);
270
+ const validate = options.parse ?? options.parser ?? options.validate ?? ((payload) => payload);
271
+ const parse = (payload) => {
272
+ let parsed;
273
+ try {
274
+ parsed = validate(payload);
275
+ } catch (error) {
276
+ throw new DcbDefinitionError("EVENT_PAYLOAD_INVALID", `Event ${name} payload was rejected`, {
277
+ cause: error
278
+ });
279
+ }
280
+ return assertJsonValue(parsed, "event-construction");
281
+ };
282
+ const create = (payload) => Object.freeze({ eventName: name, eventPayloadName, payload: parse(payload) });
283
+ return Object.freeze({ name, eventName: name, eventPayloadName, eventType, create, construct: create, parse });
284
+ }
285
+ var eventNameOf = (event) => event.eventName ?? event.eventPayloadName;
286
+ function defineProjector(options) {
287
+ const id = options.id ?? options.projectorId;
288
+ if (!id) throw new DcbDefinitionError("PROJECTOR_ID_REQUIRED", "Projector id is required");
289
+ const handlers = options.handlers ?? options.eventHandlers ?? {};
290
+ const subscribed = options.subscribedEventNames ?? options.subscriptions ?? options.events?.map((event) => typeof event === "string" ? event : event.name) ?? Object.keys(handlers);
291
+ const uniqueSubscribed = [...new Set(subscribed)];
292
+ const eventTypeHandlers = options.eventTypeHandlers ?? {};
293
+ const subscribedEventTypes = options.subscribedEventTypes ?? options.events?.map(
294
+ (event) => typeof event === "string" ? canonicalEventKey(event) : event.eventType
295
+ ) ?? uniqueSubscribed.map((name) => canonicalEventKey(name));
296
+ const uniqueSubscribedEventTypes = [...new Set(subscribedEventTypes.map((eventType) => parseCanonicalEventKey(eventType).key))];
297
+ const missing = uniqueSubscribed.filter(
298
+ (name) => handlers[name] === void 0 && !uniqueSubscribedEventTypes.some(
299
+ (eventType) => parseCanonicalEventKey(eventType).eventPayloadName === name && eventTypeHandlers[eventType] !== void 0
300
+ )
301
+ );
302
+ if (missing.length > 0) {
303
+ throw new DcbDefinitionError("PROJECTOR_HANDLER_REQUIRED", `Projector ${id} is missing handlers: ${missing.join(", ")}`);
304
+ }
305
+ const version = options.version ?? options.projectorVersion ?? 1;
306
+ if (!Number.isInteger(version) || version < 1) {
307
+ throw new DcbDefinitionError("PROJECTOR_VERSION_INVALID", "Projector version must be a positive integer");
308
+ }
309
+ const initialState = assertJsonValue(options.initialState, "state-persistence");
310
+ const apply = (state, event) => {
311
+ const eventType = "eventType" in event ? event.eventType : void 0;
312
+ const identity = eventType === void 0 ? void 0 : parseCanonicalEventKey(eventType);
313
+ const name = identity?.eventPayloadName ?? eventNameOf(event);
314
+ if (!name || !uniqueSubscribed.includes(name)) return state;
315
+ if (identity !== void 0 && !uniqueSubscribedEventTypes.includes(identity.key)) {
316
+ throw new DcbDefinitionError("EVENT_TYPE_UNREGISTERED", `Projector ${id} does not subscribe to ${identity.key}`);
317
+ }
318
+ const payload = assertJsonValue(event.payload, "event-construction");
319
+ const definedEvent = Object.freeze({
320
+ eventName: name,
321
+ eventPayloadName: event.eventPayloadName ?? name,
322
+ payload
323
+ });
324
+ const handler = identity === void 0 ? handlers[name] : eventTypeHandlers[identity.key] ?? handlers[name];
325
+ if (handler === void 0) throw new DcbDefinitionError("PROJECTOR_HANDLER_REQUIRED", `Projector ${id} has no handler for ${identity?.key ?? name}`);
326
+ const next = handler(state, definedEvent);
327
+ return assertJsonValue(next, "state-persistence");
328
+ };
329
+ const serializeState = options.serializeState ? (state) => {
330
+ assertJsonValue(state, "state-persistence");
331
+ const serialized = options.serializeState(state);
332
+ if (typeof serialized !== "string") throw new DcbDefinitionError("STATE_SERIALIZATION_INVALID", `Projector ${id} did not serialize to a string`);
333
+ return serialized;
334
+ } : (state) => JSON.stringify(assertJsonValue(state, "state-persistence"));
335
+ const deserializeState = options.deserializeState ? (serialized) => assertJsonValue(options.deserializeState(serialized), "state-persistence") : (serialized) => {
336
+ let parsed;
337
+ try {
338
+ parsed = JSON.parse(serialized);
339
+ } catch (error) {
340
+ throw new DcbDefinitionError("STATE_DESERIALIZATION_INVALID", `Projector ${id} received invalid JSON`, { cause: error });
341
+ }
342
+ return assertJsonValue(parsed, "state-persistence");
343
+ };
344
+ return Object.freeze({
345
+ id,
346
+ projectorId: id,
347
+ version,
348
+ projectorVersion: version,
349
+ subscribedEventNames: Object.freeze(uniqueSubscribed),
350
+ subscribedEventTypes: Object.freeze(uniqueSubscribedEventTypes),
351
+ initialState,
352
+ apply,
353
+ reduce: apply,
354
+ serializeState,
355
+ deserializeState
356
+ });
357
+ }
358
+ var done = (value, state) => Object.freeze({ kind: "committed", value, state });
359
+ var noop = (reason) => Object.freeze({ kind: "noop", reason, events: [] });
360
+ var reject = (reason, code = "command_rejected") => Object.freeze({ kind: "rejected", reason, code, events: [] });
361
+ function defineCommand(options) {
362
+ const id = options.id ?? options.name;
363
+ if (!id) throw new DcbDefinitionError("COMMAND_ID_REQUIRED", "Command id is required");
364
+ const parser = options.parseInput ?? options.inputParser ?? options.input;
365
+ if (!parser) throw new DcbDefinitionError("COMMAND_INPUT_PARSER_REQUIRED", `Command ${id} requires an input parser`);
366
+ const parseInput = (input) => {
367
+ let parsed;
368
+ try {
369
+ parsed = parser(input);
370
+ } catch (error) {
371
+ throw new DcbDefinitionError("COMMAND_INPUT_INVALID", `Command ${id} input was rejected`, { cause: error });
372
+ }
373
+ assertJsonValue(parsed, "command-input");
374
+ return parsed;
375
+ };
376
+ const execute = (input, executionOptions) => {
377
+ const appended = [];
378
+ const stateMap = executionOptions?.state ?? {};
379
+ const context = {
380
+ state: (tag) => stateMap[defineTag(tag).id],
381
+ assertEmpty: (tag) => {
382
+ if (stateMap[defineTag(tag).id] !== void 0) throw new DcbDefinitionError("ASSERT_EMPTY_FAILED", `Tag ${defineTag(tag).id} is not empty`);
383
+ },
384
+ append: (event, payload, tags = []) => {
385
+ const entry = Object.freeze({ event, payload: event.parse(payload), tags: Object.freeze(tags.map((tag) => defineTag(tag))) });
386
+ appended.push(entry);
387
+ return entry;
388
+ },
389
+ done: (value) => Object.freeze({ kind: "committed", value }),
390
+ noop: (reason) => Object.freeze({ kind: "noop", reason }),
391
+ reject: (reason, code) => Object.freeze({ kind: "rejected", reason, code: code ?? "command_rejected" }),
392
+ appendedEvents: appended
393
+ };
394
+ const outcome = options.handler(parseInput(input), context);
395
+ if (!outcome || typeof outcome !== "object" || !["committed", "noop", "rejected"].includes(outcome.kind)) {
396
+ throw new DcbDefinitionError("COMMAND_OUTCOME_INVALID", `Command ${id} must return done, noop, or reject`);
397
+ }
398
+ if (outcome.kind === "committed") return Object.freeze({ ...outcome, events: Object.freeze([...appended]) });
399
+ if (appended.length > 0) throw new DcbDefinitionError("COMMAND_EVENTS_WITHOUT_COMMIT", `Command ${id} appended events but did not return done`);
400
+ return outcome.kind === "noop" ? Object.freeze({ ...outcome, events: [] }) : Object.freeze({ ...outcome, events: [] });
401
+ };
402
+ return Object.freeze({ id, name: id, parseInput, execute, handle: execute });
403
+ }
404
+ var DomainDefinitionError = class extends DcbDefinitionError {
405
+ code = "DOMAIN_DEFINITION_INVALID";
406
+ collisions;
407
+ constructor(collisions) {
408
+ super("DOMAIN_DEFINITION_INVALID", `Domain contains ${collisions.length} duplicate definition collision(s)`);
409
+ this.name = "DomainDefinitionError";
410
+ this.collisions = Object.freeze([...collisions]);
411
+ }
412
+ };
413
+ var componentId = (component) => component.id ?? component.name ?? "";
414
+ var componentVersion = (component) => component.version ?? component.projectorVersion ?? component.queryVersion;
415
+ function defineDomain(options) {
416
+ const events = [...options.events ?? []];
417
+ const commands = [...options.commands ?? []];
418
+ const projectors = [...options.projectors ?? []];
419
+ const queries = [...options.queries ?? []];
420
+ const materializedViews = [...options.materializedViews ?? options.mvs ?? []];
421
+ const collisions = [];
422
+ const collect = (kind, values) => {
423
+ const indexesById = /* @__PURE__ */ new Map();
424
+ values.forEach((id, index) => {
425
+ if (id.length === 0) return;
426
+ const indexes = indexesById.get(id) ?? [];
427
+ indexes.push(index);
428
+ indexesById.set(id, indexes);
429
+ });
430
+ for (const [id, indexes] of indexesById) {
431
+ if (indexes.length > 1) collisions.push({ kind, id, indexes: Object.freeze(indexes) });
432
+ }
433
+ };
434
+ collect("event-name", events.map((event) => event.name));
435
+ collect("command-id", commands.map((command) => command.id));
436
+ collect("projector-id", projectors.map((projector) => projector.id));
437
+ collect("query-id", queries.map(componentId));
438
+ collect("materialized-view-id", materializedViews.map(componentId));
439
+ const collectVersionPairs = (components, kind) => {
440
+ const pairs = /* @__PURE__ */ new Map();
441
+ components.forEach((component, index) => {
442
+ const id = componentId(component);
443
+ const version = componentVersion(component);
444
+ if (id.length === 0 || version === void 0) return;
445
+ const key = `${id}\0${version}`;
446
+ const indexes = pairs.get(key) ?? [];
447
+ indexes.push(index);
448
+ pairs.set(key, indexes);
449
+ });
450
+ for (const [key, indexes] of pairs) {
451
+ if (indexes.length > 1) {
452
+ const [id, version] = key.split("\0");
453
+ collisions.push({ kind, id, version: Number(version), indexes: Object.freeze(indexes) });
454
+ }
455
+ }
456
+ };
457
+ collectVersionPairs(projectors, "version-pair");
458
+ collectVersionPairs(queries, "version-pair");
459
+ collectVersionPairs(materializedViews, "version-pair");
460
+ if (collisions.length > 0) throw new DomainDefinitionError(collisions);
461
+ return Object.freeze({
462
+ events: Object.freeze(events),
463
+ commands: Object.freeze(commands),
464
+ projectors: Object.freeze(projectors),
465
+ queries: Object.freeze(queries),
466
+ materializedViews: Object.freeze(materializedViews),
467
+ eventByName: new Map(events.map((event) => [event.name, event]))
468
+ });
469
+ }
470
+ export {
471
+ CanonicalEventIdentityError,
472
+ DcbDefinitionError,
473
+ DomainDefinitionError,
474
+ JsonValidationError,
475
+ assertJsonValue,
476
+ canonicalEventKey,
477
+ defineCommand,
478
+ defineDomain,
479
+ defineEvent,
480
+ defineMaterializedView,
481
+ defineMaterializedViewRowMaterializer,
482
+ defineProjector,
483
+ defineRowMaterializer,
484
+ defineTag,
485
+ done,
486
+ noop,
487
+ parseCanonicalEventKey,
488
+ reject,
489
+ validateJsonValue
490
+ };
@@ -0,0 +1,107 @@
1
+ import { type JsonValue } from "./index";
2
+ /** The only scalar representations that may be persisted in an MV index. */
3
+ export type MaterializedViewIndexValueType = "text" | "integer" | "real";
4
+ export type MaterializedViewIndexValue = string | number;
5
+ /** A finite, deploy-time index descriptor. Its callback is pure by contract. */
6
+ export interface MaterializedViewIndexDescriptor<TEvent = unknown> {
7
+ readonly id: string;
8
+ readonly valueType: MaterializedViewIndexValueType;
9
+ readonly value: (row: JsonValue, event: TEvent) => unknown;
10
+ }
11
+ export interface MaterializedViewRowUpsert {
12
+ readonly rowKey: string;
13
+ readonly value: JsonValue;
14
+ readonly rowVersion: number;
15
+ readonly sourceSuid: string;
16
+ }
17
+ export interface MaterializedViewRowDelete {
18
+ readonly rowKey: string;
19
+ }
20
+ /**
21
+ * A declarative partial-row mutation. The runtime applies `patch` with a
22
+ * database JSON function; it must not read/merge the row in TypeScript.
23
+ * When index entries are supplied, they replace this row's index entries in
24
+ * the same atomic batch. An empty list preserves existing entries.
25
+ */
26
+ export interface MaterializedViewRowPatch {
27
+ readonly kind: "json_patch";
28
+ readonly rowKey: string;
29
+ readonly patch: JsonValue;
30
+ readonly rowVersion: number;
31
+ readonly sourceSuid: string;
32
+ readonly indexEntries: readonly MaterializedViewIndexEntryMutation[];
33
+ }
34
+ export interface MaterializedViewIndexEntryMutation {
35
+ readonly indexId: string;
36
+ readonly valueType: MaterializedViewIndexValueType;
37
+ readonly value: MaterializedViewIndexValue;
38
+ readonly rowKey: string;
39
+ }
40
+ export interface MaterializedViewIndexEntryDelete {
41
+ readonly indexId?: string;
42
+ readonly rowKey: string;
43
+ }
44
+ /**
45
+ * A complete deterministic write plan for one source event. The runtime
46
+ * turns this value into prepared statements; no request-derived value is ever
47
+ * used as a SQL identifier.
48
+ */
49
+ export interface MaterializedViewMutationPlan {
50
+ readonly rowUpserts: readonly MaterializedViewRowUpsert[];
51
+ readonly rowDeletes: readonly MaterializedViewRowDelete[];
52
+ readonly rowPatches: readonly MaterializedViewRowPatch[];
53
+ readonly indexEntries: readonly MaterializedViewIndexEntryMutation[];
54
+ readonly indexDeletes: readonly MaterializedViewIndexEntryDelete[];
55
+ }
56
+ export interface MaterializedViewRowMaterializerOptions<TEvent = unknown> {
57
+ readonly id: string;
58
+ readonly version?: number;
59
+ readonly indexDescriptors?: readonly MaterializedViewIndexDescriptor<TEvent>[];
60
+ readonly indexes?: readonly MaterializedViewIndexDescriptor<TEvent>[];
61
+ /** Return row upserts/deletes, or a complete plan when custom indexes are needed. */
62
+ readonly materialize?: (event: TEvent) => MaterializedViewMaterializeResult;
63
+ readonly plan?: (event: TEvent) => MaterializedViewMaterializeResult;
64
+ }
65
+ export type MaterializedViewMaterializeResult = {
66
+ readonly rowUpserts?: readonly MaterializedViewRowInput[];
67
+ readonly upserts?: readonly MaterializedViewRowInput[];
68
+ readonly rowDeletes?: readonly (string | MaterializedViewRowDelete)[];
69
+ readonly deletes?: readonly (string | MaterializedViewRowDelete)[];
70
+ readonly rowPatches?: readonly MaterializedViewRowPatchInput[];
71
+ readonly patches?: readonly MaterializedViewRowPatchInput[];
72
+ readonly indexEntries?: readonly MaterializedViewIndexEntryInput[];
73
+ readonly indexDeletes?: readonly MaterializedViewIndexEntryDelete[];
74
+ } | MaterializedViewMutationPlan;
75
+ export interface MaterializedViewRowInput {
76
+ readonly rowKey: string;
77
+ readonly value: unknown;
78
+ readonly rowVersion?: number;
79
+ readonly sourceSuid?: string;
80
+ }
81
+ export interface MaterializedViewRowPatchInput {
82
+ readonly kind: "json_patch";
83
+ readonly rowKey: string;
84
+ readonly patch: unknown;
85
+ readonly rowVersion?: number;
86
+ readonly sourceSuid?: string;
87
+ readonly indexEntries?: readonly MaterializedViewIndexEntryInput[];
88
+ }
89
+ export interface MaterializedViewIndexEntryInput {
90
+ readonly indexId: string;
91
+ readonly valueType?: MaterializedViewIndexValueType;
92
+ readonly value: unknown;
93
+ readonly rowKey: string;
94
+ }
95
+ export interface MaterializedViewRowMaterializer<TEvent = unknown> {
96
+ readonly id: string;
97
+ readonly version: number;
98
+ readonly indexDescriptors: readonly MaterializedViewIndexDescriptor<TEvent>[];
99
+ readonly plan: (event: TEvent) => MaterializedViewMutationPlan;
100
+ readonly planFor: (event: TEvent) => MaterializedViewMutationPlan;
101
+ readonly mutationPlan: (event: TEvent) => MaterializedViewMutationPlan;
102
+ }
103
+ export type RowMaterializerDefinition<TEvent = unknown> = MaterializedViewRowMaterializer<TEvent>;
104
+ /** Define a finite, JSON-validating, deterministic row materializer. */
105
+ export declare function defineRowMaterializer<TEvent = unknown>(options: MaterializedViewRowMaterializerOptions<TEvent>): MaterializedViewRowMaterializer<TEvent>;
106
+ export declare const defineMaterializedView: typeof defineRowMaterializer;
107
+ export declare const defineMaterializedViewRowMaterializer: typeof defineRowMaterializer;
@@ -0,0 +1,173 @@
1
+ import { assertJsonValue } from "./index";
2
+ function nonEmpty(value, code, message) {
3
+ if (typeof value !== "string" || value.length === 0)
4
+ throw new Error(`${code}: ${message}`);
5
+ }
6
+ function descriptorValue(descriptor, value) {
7
+ if (descriptor.valueType === "text") {
8
+ if (typeof value !== "string")
9
+ throw new Error(`MV index ${descriptor.id} expected a text value`);
10
+ return value;
11
+ }
12
+ if (typeof value !== "number" || !Number.isFinite(value)) {
13
+ throw new Error(`MV index ${descriptor.id} expected a finite numeric value`);
14
+ }
15
+ if (descriptor.valueType === "integer" && (!Number.isSafeInteger(value) || !Number.isInteger(value))) {
16
+ throw new Error(`MV index ${descriptor.id} expected a safe integer value`);
17
+ }
18
+ return value;
19
+ }
20
+ function validateDescriptors(descriptors) {
21
+ const seen = new Set();
22
+ for (const descriptor of descriptors) {
23
+ nonEmpty(descriptor.id, "MV_INDEX_ID_REQUIRED", "Index descriptor id is required");
24
+ if (seen.has(descriptor.id))
25
+ throw new Error(`MV_INDEX_DUPLICATE: ${descriptor.id}`);
26
+ seen.add(descriptor.id);
27
+ if (!["text", "integer", "real"].includes(descriptor.valueType)) {
28
+ throw new Error(`MV_INDEX_VALUE_TYPE_INVALID: ${descriptor.id}`);
29
+ }
30
+ if (typeof descriptor.value !== "function")
31
+ throw new Error(`MV_INDEX_VALUE_REQUIRED: ${descriptor.id}`);
32
+ }
33
+ return Object.freeze(descriptors.map((descriptor) => Object.freeze({ ...descriptor })));
34
+ }
35
+ function normalizeRowInput(input, event) {
36
+ nonEmpty(input.rowKey, "MV_ROW_KEY_REQUIRED", "Row key is required");
37
+ const value = assertJsonValue(input.value, "state-persistence");
38
+ const rowVersion = input.rowVersion ?? 1;
39
+ if (!Number.isSafeInteger(rowVersion) || rowVersion < 0)
40
+ throw new Error(`MV_ROW_VERSION_INVALID: ${input.rowKey}`);
41
+ const sourceSuid = input.sourceSuid ?? (typeof event === "object" && event !== null && "suid" in event && typeof event.suid === "string"
42
+ ? event.suid
43
+ : "");
44
+ nonEmpty(sourceSuid, "MV_SOURCE_SUID_REQUIRED", `Source SUID is required for ${input.rowKey}`);
45
+ return Object.freeze({ rowKey: input.rowKey, value, rowVersion, sourceSuid });
46
+ }
47
+ function normalizeRowDeletes(values = []) {
48
+ const keys = new Set();
49
+ for (const value of values) {
50
+ const rowKey = typeof value === "string" ? value : value.rowKey;
51
+ nonEmpty(rowKey, "MV_ROW_KEY_REQUIRED", "Row delete key is required");
52
+ keys.add(rowKey);
53
+ }
54
+ return Object.freeze([...keys].map((rowKey) => Object.freeze({ rowKey })));
55
+ }
56
+ function normalizeIndexEntries(values, descriptors, rowKey) {
57
+ const descriptorById = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
58
+ return Object.freeze(values.map((entry) => {
59
+ nonEmpty(entry.indexId, "MV_INDEX_ID_REQUIRED", "Index entry id is required");
60
+ const descriptor = descriptorById.get(entry.indexId);
61
+ if (descriptor === undefined)
62
+ throw new Error(`MV_INDEX_UNDECLARED: ${entry.indexId}`);
63
+ nonEmpty(entry.rowKey, "MV_ROW_KEY_REQUIRED", "Index entry row key is required");
64
+ if (rowKey !== undefined && entry.rowKey !== rowKey) {
65
+ throw new Error(`MV_INDEX_ROW_KEY_MISMATCH: ${entry.indexId}`);
66
+ }
67
+ const valueType = entry.valueType ?? descriptor.valueType;
68
+ if (valueType !== descriptor.valueType)
69
+ throw new Error(`MV_INDEX_VALUE_TYPE_MISMATCH: ${entry.indexId}`);
70
+ return Object.freeze({
71
+ indexId: entry.indexId,
72
+ valueType,
73
+ value: descriptorValue(descriptor, entry.value),
74
+ rowKey: entry.rowKey,
75
+ });
76
+ }));
77
+ }
78
+ function normalizeRowPatches(values, descriptors, event) {
79
+ return Object.freeze(values.map((input) => {
80
+ nonEmpty(input.rowKey, "MV_ROW_KEY_REQUIRED", "Row patch key is required");
81
+ if (input.kind !== "json_patch")
82
+ throw new Error(`MV_PATCH_KIND_INVALID: ${input.rowKey}`);
83
+ const patch = assertJsonValue(input.patch, "state-persistence");
84
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
85
+ throw new Error(`MV_PATCH_OBJECT_REQUIRED: ${input.rowKey}`);
86
+ }
87
+ const rowVersion = input.rowVersion ?? 1;
88
+ if (!Number.isSafeInteger(rowVersion) || rowVersion < 0)
89
+ throw new Error(`MV_ROW_VERSION_INVALID: ${input.rowKey}`);
90
+ const sourceSuid = input.sourceSuid ?? (typeof event === "object" && event !== null && "suid" in event && typeof event.suid === "string"
91
+ ? event.suid
92
+ : "");
93
+ nonEmpty(sourceSuid, "MV_SOURCE_SUID_REQUIRED", `Source SUID is required for ${input.rowKey}`);
94
+ return Object.freeze({
95
+ kind: "json_patch",
96
+ rowKey: input.rowKey,
97
+ patch,
98
+ rowVersion,
99
+ sourceSuid,
100
+ indexEntries: normalizeIndexEntries(input.indexEntries ?? [], descriptors, input.rowKey),
101
+ });
102
+ }));
103
+ }
104
+ function normalizePlan(result, descriptors, event) {
105
+ const value = result;
106
+ const rawUpserts = (value.rowUpserts ?? value.upserts ?? []);
107
+ const rawDeletes = (value.rowDeletes ?? value.deletes ?? []);
108
+ const rawPatches = (value.rowPatches ?? value.patches ?? []);
109
+ const rowUpserts = rawUpserts.map((row) => normalizeRowInput(row, event));
110
+ const rowDeletes = normalizeRowDeletes(rawDeletes);
111
+ const rowPatches = normalizeRowPatches(rawPatches, descriptors, event);
112
+ const descriptorById = new Map(descriptors.map((descriptor) => [descriptor.id, descriptor]));
113
+ const explicitEntries = (value.indexEntries ?? []);
114
+ const generatedEntries = [];
115
+ for (const row of rowUpserts) {
116
+ for (const descriptor of descriptors) {
117
+ let candidate;
118
+ try {
119
+ candidate = descriptor.value(row.value, event);
120
+ }
121
+ catch (error) {
122
+ throw new Error(`MV_INDEX_VALUE_FAILED: ${descriptor.id}: ${String(error)}`);
123
+ }
124
+ if (candidate === undefined || candidate === null)
125
+ continue;
126
+ generatedEntries.push(Object.freeze({
127
+ indexId: descriptor.id,
128
+ valueType: descriptor.valueType,
129
+ value: descriptorValue(descriptor, candidate),
130
+ rowKey: row.rowKey,
131
+ }));
132
+ }
133
+ }
134
+ generatedEntries.push(...normalizeIndexEntries(explicitEntries, descriptors));
135
+ const indexDeletes = Object.freeze((value.indexDeletes ?? []).map((entry) => {
136
+ nonEmpty(entry.rowKey, "MV_ROW_KEY_REQUIRED", "Index delete row key is required");
137
+ if (entry.indexId !== undefined) {
138
+ nonEmpty(entry.indexId, "MV_INDEX_ID_REQUIRED", "Index delete id is required");
139
+ if (!descriptorById.has(entry.indexId))
140
+ throw new Error(`MV_INDEX_UNDECLARED: ${entry.indexId}`);
141
+ }
142
+ return Object.freeze({ indexId: entry.indexId, rowKey: entry.rowKey });
143
+ }));
144
+ return Object.freeze({
145
+ rowUpserts: Object.freeze(rowUpserts),
146
+ rowDeletes,
147
+ rowPatches,
148
+ indexEntries: Object.freeze(generatedEntries),
149
+ indexDeletes,
150
+ });
151
+ }
152
+ /** Define a finite, JSON-validating, deterministic row materializer. */
153
+ export function defineRowMaterializer(options) {
154
+ nonEmpty(options.id, "MV_ID_REQUIRED", "Materialized-view id is required");
155
+ const version = options.version ?? 1;
156
+ if (!Number.isSafeInteger(version) || version < 1)
157
+ throw new Error("MV_VERSION_INVALID: version must be positive");
158
+ const descriptors = validateDescriptors(options.indexDescriptors ?? options.indexes ?? []);
159
+ const materialize = options.materialize ?? options.plan;
160
+ if (typeof materialize !== "function")
161
+ throw new Error(`MV_MATERIALIZER_REQUIRED: ${options.id}`);
162
+ const plan = (event) => normalizePlan(materialize(event), descriptors, event);
163
+ return Object.freeze({
164
+ id: options.id,
165
+ version,
166
+ indexDescriptors: descriptors,
167
+ plan,
168
+ planFor: plan,
169
+ mutationPlan: plan,
170
+ });
171
+ }
172
+ export const defineMaterializedView = defineRowMaterializer;
173
+ export const defineMaterializedViewRowMaterializer = defineRowMaterializer;
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@sekiban/dcb-core",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Cloudflare-independent Serialized DCB definitions and domain algebra.",
6
+ "license": "Elastic-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/J-Tech-Japan/sekiban-dcb-ts.git",
10
+ "directory": "packages/dcb-core"
11
+ },
12
+ "homepage": "https://github.com/J-Tech-Japan/sekiban-dcb-ts/tree/main/packages/dcb-core#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/J-Tech-Japan/sekiban-dcb-ts/issues"
15
+ },
16
+ "keywords": [
17
+ "sekiban",
18
+ "dcb",
19
+ "core",
20
+ "serialized-dcb"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
32
+ },
33
+ "types": "./dist/index.d.ts",
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json && esbuild src/index.ts --bundle --format=esm --platform=neutral --outfile=dist/index.js",
44
+ "typecheck": "tsc -p tsconfig.json --noEmit"
45
+ }
46
+ }