@setzkasten-cms/core 0.4.2
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 +37 -0
- package/dist/chunk-IL2PWN4R.js +142 -0
- package/dist/define-config-bJ65Zaui.d.ts +209 -0
- package/dist/index.d.ts +261 -0
- package/dist/index.js +314 -0
- package/dist/testing.d.ts +28 -0
- package/dist/testing.js +69 -0
- package/package.json +32 -0
- package/src/commands/command.test.ts +85 -0
- package/src/commands/command.ts +65 -0
- package/src/errors/errors.ts +112 -0
- package/src/events/content-event-bus.test.ts +59 -0
- package/src/events/content-event-bus.ts +55 -0
- package/src/fields/factories.test.ts +168 -0
- package/src/fields/factories.ts +166 -0
- package/src/fields/field-definition.ts +215 -0
- package/src/index.ts +85 -0
- package/src/ports/asset-store.ts +36 -0
- package/src/ports/auth-provider.ts +32 -0
- package/src/ports/content-repository.ts +59 -0
- package/src/schema/define-config.ts +96 -0
- package/src/serialization/json-serializer.ts +87 -0
- package/src/serialization/serializer.ts +65 -0
- package/src/testing/index.ts +72 -0
- package/src/validation/schema-to-zod.test.ts +133 -0
- package/src/validation/schema-to-zod.ts +126 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineCollection,
|
|
3
|
+
defineConfig,
|
|
4
|
+
defineSection,
|
|
5
|
+
f
|
|
6
|
+
} from "./chunk-IL2PWN4R.js";
|
|
7
|
+
|
|
8
|
+
// src/validation/schema-to-zod.ts
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
function fieldToZod(field) {
|
|
11
|
+
switch (field.type) {
|
|
12
|
+
case "text":
|
|
13
|
+
return textToZod(field);
|
|
14
|
+
case "number":
|
|
15
|
+
return numberToZod(field);
|
|
16
|
+
case "boolean":
|
|
17
|
+
return z.boolean();
|
|
18
|
+
case "select":
|
|
19
|
+
return selectToZod(field);
|
|
20
|
+
case "icon":
|
|
21
|
+
return field.required ? z.string().min(1, `${field.label} ist erforderlich`) : z.string();
|
|
22
|
+
case "image":
|
|
23
|
+
return imageToZod(field);
|
|
24
|
+
case "array":
|
|
25
|
+
return arrayToZod(field);
|
|
26
|
+
case "object":
|
|
27
|
+
return objectToZod(field);
|
|
28
|
+
case "color":
|
|
29
|
+
return z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color");
|
|
30
|
+
case "override":
|
|
31
|
+
return overrideToZod(field);
|
|
32
|
+
default:
|
|
33
|
+
return z.unknown();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function textToZod(field) {
|
|
37
|
+
let schema = z.string();
|
|
38
|
+
if (field.required) {
|
|
39
|
+
schema = schema.min(1, `${field.label} ist erforderlich`);
|
|
40
|
+
}
|
|
41
|
+
if (field.maxLength) {
|
|
42
|
+
schema = schema.max(field.maxLength, `${field.label} darf max. ${field.maxLength} Zeichen haben`);
|
|
43
|
+
}
|
|
44
|
+
if (field.pattern) {
|
|
45
|
+
schema = schema.regex(field.pattern, `${field.label} hat ein ung\xFCltiges Format`);
|
|
46
|
+
}
|
|
47
|
+
return schema;
|
|
48
|
+
}
|
|
49
|
+
function numberToZod(field) {
|
|
50
|
+
let schema = z.number();
|
|
51
|
+
if (field.min !== void 0) {
|
|
52
|
+
schema = schema.min(field.min);
|
|
53
|
+
}
|
|
54
|
+
if (field.max !== void 0) {
|
|
55
|
+
schema = schema.max(field.max);
|
|
56
|
+
}
|
|
57
|
+
return schema;
|
|
58
|
+
}
|
|
59
|
+
function selectToZod(field) {
|
|
60
|
+
const values = field.options.map((o) => o.value);
|
|
61
|
+
if (values.length === 0) return z.string();
|
|
62
|
+
const [first, ...rest] = values;
|
|
63
|
+
return z.enum([first, ...rest]);
|
|
64
|
+
}
|
|
65
|
+
function imageToZod(field) {
|
|
66
|
+
const schema = z.object({
|
|
67
|
+
path: z.string(),
|
|
68
|
+
alt: z.string().optional()
|
|
69
|
+
});
|
|
70
|
+
return field.required ? schema.refine((v) => v.path.length > 0, `${field.label} ist erforderlich`) : schema;
|
|
71
|
+
}
|
|
72
|
+
function arrayToZod(field) {
|
|
73
|
+
const itemSchema = fieldToZod(field.itemField);
|
|
74
|
+
let schema = z.array(itemSchema);
|
|
75
|
+
if (field.minItems !== void 0) {
|
|
76
|
+
schema = schema.min(field.minItems);
|
|
77
|
+
}
|
|
78
|
+
if (field.maxItems !== void 0) {
|
|
79
|
+
schema = schema.max(field.maxItems);
|
|
80
|
+
}
|
|
81
|
+
return schema;
|
|
82
|
+
}
|
|
83
|
+
function objectToZod(field) {
|
|
84
|
+
return schemaToZod(field.fields);
|
|
85
|
+
}
|
|
86
|
+
function overrideToZod(field) {
|
|
87
|
+
const innerSchema = schemaToZod(field.fields);
|
|
88
|
+
return z.object({
|
|
89
|
+
active: z.boolean()
|
|
90
|
+
}).and(innerSchema.partial());
|
|
91
|
+
}
|
|
92
|
+
function schemaToZod(fields) {
|
|
93
|
+
const shape = {};
|
|
94
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
95
|
+
const zodField = fieldToZod(field);
|
|
96
|
+
shape[key] = field.required ? zodField : zodField.optional();
|
|
97
|
+
}
|
|
98
|
+
return z.object(shape);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/serialization/serializer.ts
|
|
102
|
+
function serializeEntry(fields, values, registry) {
|
|
103
|
+
const result = {};
|
|
104
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
105
|
+
const serializer = registry[field.type];
|
|
106
|
+
const value = values[key];
|
|
107
|
+
if (serializer && value !== void 0) {
|
|
108
|
+
result[key] = serializer.serialize(value);
|
|
109
|
+
} else if (value !== void 0) {
|
|
110
|
+
result[key] = value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
function deserializeEntry(fields, raw, registry) {
|
|
116
|
+
const result = {};
|
|
117
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
118
|
+
const serializer = registry[field.type];
|
|
119
|
+
const rawValue = raw[key];
|
|
120
|
+
if (serializer && rawValue !== void 0) {
|
|
121
|
+
result[key] = serializer.deserialize(rawValue);
|
|
122
|
+
} else if (rawValue !== void 0) {
|
|
123
|
+
result[key] = rawValue;
|
|
124
|
+
} else if (field.defaultValue !== void 0) {
|
|
125
|
+
result[key] = field.defaultValue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/serialization/json-serializer.ts
|
|
132
|
+
var textSerializer = {
|
|
133
|
+
serialize: (value) => value,
|
|
134
|
+
deserialize: (raw) => typeof raw === "string" ? raw : String(raw ?? "")
|
|
135
|
+
};
|
|
136
|
+
var numberSerializer = {
|
|
137
|
+
serialize: (value) => value,
|
|
138
|
+
deserialize: (raw) => typeof raw === "number" ? raw : Number(raw ?? 0)
|
|
139
|
+
};
|
|
140
|
+
var booleanSerializer = {
|
|
141
|
+
serialize: (value) => value,
|
|
142
|
+
deserialize: (raw) => Boolean(raw)
|
|
143
|
+
};
|
|
144
|
+
var selectSerializer = {
|
|
145
|
+
serialize: (value) => value,
|
|
146
|
+
deserialize: (raw) => String(raw ?? "")
|
|
147
|
+
};
|
|
148
|
+
var imageSerializer = {
|
|
149
|
+
serialize: (value) => {
|
|
150
|
+
if (typeof value === "string") return value;
|
|
151
|
+
if (value && typeof value === "object" && "path" in value) {
|
|
152
|
+
return value.path;
|
|
153
|
+
}
|
|
154
|
+
return "";
|
|
155
|
+
},
|
|
156
|
+
deserialize: (raw) => {
|
|
157
|
+
if (typeof raw === "string") return { path: raw, alt: "" };
|
|
158
|
+
if (raw && typeof raw === "object" && "path" in raw) return raw;
|
|
159
|
+
return { path: "", alt: "" };
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
var colorSerializer = {
|
|
163
|
+
serialize: (value) => value,
|
|
164
|
+
deserialize: (raw) => String(raw ?? "#000000")
|
|
165
|
+
};
|
|
166
|
+
var jsonSerializerRegistry = {
|
|
167
|
+
text: textSerializer,
|
|
168
|
+
number: numberSerializer,
|
|
169
|
+
boolean: booleanSerializer,
|
|
170
|
+
select: selectSerializer,
|
|
171
|
+
image: imageSerializer,
|
|
172
|
+
color: colorSerializer,
|
|
173
|
+
// array, object, override are handled recursively by serializeEntry/deserializeEntry
|
|
174
|
+
array: {
|
|
175
|
+
serialize: (value) => Array.isArray(value) ? value : [],
|
|
176
|
+
deserialize: (raw) => Array.isArray(raw) ? raw : []
|
|
177
|
+
},
|
|
178
|
+
object: {
|
|
179
|
+
serialize: (value) => value && typeof value === "object" ? value : {},
|
|
180
|
+
deserialize: (raw) => raw && typeof raw === "object" ? raw : {}
|
|
181
|
+
},
|
|
182
|
+
override: {
|
|
183
|
+
serialize: (value) => {
|
|
184
|
+
if (value && typeof value === "object") return value;
|
|
185
|
+
return { active: false };
|
|
186
|
+
},
|
|
187
|
+
deserialize: (raw) => {
|
|
188
|
+
if (raw && typeof raw === "object") return raw;
|
|
189
|
+
return { active: false };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// src/commands/command.ts
|
|
195
|
+
function createCommandHistory() {
|
|
196
|
+
const undoStack = [];
|
|
197
|
+
const redoStack = [];
|
|
198
|
+
return {
|
|
199
|
+
execute(command) {
|
|
200
|
+
command.execute();
|
|
201
|
+
undoStack.push(command);
|
|
202
|
+
redoStack.length = 0;
|
|
203
|
+
},
|
|
204
|
+
undo() {
|
|
205
|
+
const command = undoStack.pop();
|
|
206
|
+
if (command) {
|
|
207
|
+
command.undo();
|
|
208
|
+
redoStack.push(command);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
redo() {
|
|
212
|
+
const command = redoStack.pop();
|
|
213
|
+
if (command) {
|
|
214
|
+
command.execute();
|
|
215
|
+
undoStack.push(command);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
canUndo() {
|
|
219
|
+
return undoStack.length > 0;
|
|
220
|
+
},
|
|
221
|
+
canRedo() {
|
|
222
|
+
return redoStack.length > 0;
|
|
223
|
+
},
|
|
224
|
+
clear() {
|
|
225
|
+
undoStack.length = 0;
|
|
226
|
+
redoStack.length = 0;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/events/content-event-bus.ts
|
|
232
|
+
function createEventBus() {
|
|
233
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
234
|
+
return {
|
|
235
|
+
emit(event) {
|
|
236
|
+
const handlers = listeners.get(event.type);
|
|
237
|
+
if (handlers) {
|
|
238
|
+
for (const handler of handlers) {
|
|
239
|
+
handler(event);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
on(type, handler) {
|
|
244
|
+
if (!listeners.has(type)) {
|
|
245
|
+
listeners.set(type, /* @__PURE__ */ new Set());
|
|
246
|
+
}
|
|
247
|
+
const handlers = listeners.get(type);
|
|
248
|
+
handlers.add(handler);
|
|
249
|
+
return () => {
|
|
250
|
+
handlers.delete(handler);
|
|
251
|
+
if (handlers.size === 0) {
|
|
252
|
+
listeners.delete(type);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/errors/errors.ts
|
|
260
|
+
function ok(value) {
|
|
261
|
+
return { ok: true, value };
|
|
262
|
+
}
|
|
263
|
+
function err(error) {
|
|
264
|
+
return { ok: false, error };
|
|
265
|
+
}
|
|
266
|
+
function validationError(fieldPath, rule, message) {
|
|
267
|
+
return { type: "validation", fieldPath, rule, message, timestamp: Date.now() };
|
|
268
|
+
}
|
|
269
|
+
function conflictError(message) {
|
|
270
|
+
return { type: "conflict", message, timestamp: Date.now() };
|
|
271
|
+
}
|
|
272
|
+
function rateLimitError(retryAfter, remaining) {
|
|
273
|
+
return {
|
|
274
|
+
type: "rate-limit",
|
|
275
|
+
retryAfter,
|
|
276
|
+
remaining,
|
|
277
|
+
message: `Rate limit exceeded. Retry after ${retryAfter}s`,
|
|
278
|
+
timestamp: Date.now()
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function authError(message) {
|
|
282
|
+
return { type: "auth", message, timestamp: Date.now() };
|
|
283
|
+
}
|
|
284
|
+
function networkError(message, cause) {
|
|
285
|
+
return { type: "network", message, cause, timestamp: Date.now() };
|
|
286
|
+
}
|
|
287
|
+
function notFoundError(path) {
|
|
288
|
+
return { type: "not-found", path, message: `Not found: ${path}`, timestamp: Date.now() };
|
|
289
|
+
}
|
|
290
|
+
function serializationError(message, path) {
|
|
291
|
+
return { type: "serialization", message, path, timestamp: Date.now() };
|
|
292
|
+
}
|
|
293
|
+
export {
|
|
294
|
+
authError,
|
|
295
|
+
conflictError,
|
|
296
|
+
createCommandHistory,
|
|
297
|
+
createEventBus,
|
|
298
|
+
defineCollection,
|
|
299
|
+
defineConfig,
|
|
300
|
+
defineSection,
|
|
301
|
+
deserializeEntry,
|
|
302
|
+
err,
|
|
303
|
+
f,
|
|
304
|
+
fieldToZod,
|
|
305
|
+
jsonSerializerRegistry,
|
|
306
|
+
networkError,
|
|
307
|
+
notFoundError,
|
|
308
|
+
ok,
|
|
309
|
+
rateLimitError,
|
|
310
|
+
schemaToZod,
|
|
311
|
+
serializationError,
|
|
312
|
+
serializeEntry,
|
|
313
|
+
validationError
|
|
314
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { y as SetzKastenConfig } from './define-config-bJ65Zaui.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @setzkasten-cms/core/testing – Test utilities for other packages
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Creates a minimal test config for use in tests.
|
|
8
|
+
*/
|
|
9
|
+
declare function createTestConfig(): SetzKastenConfig;
|
|
10
|
+
/**
|
|
11
|
+
* Creates sample content data matching the test config schema.
|
|
12
|
+
*/
|
|
13
|
+
declare function createTestContent(): {
|
|
14
|
+
hero: {
|
|
15
|
+
heading: string;
|
|
16
|
+
subtitle: string;
|
|
17
|
+
badge: string;
|
|
18
|
+
};
|
|
19
|
+
benefits: {
|
|
20
|
+
items: {
|
|
21
|
+
title: string;
|
|
22
|
+
description: string;
|
|
23
|
+
icon: string;
|
|
24
|
+
}[];
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export { createTestConfig, createTestContent };
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineConfig,
|
|
3
|
+
defineSection,
|
|
4
|
+
f
|
|
5
|
+
} from "./chunk-IL2PWN4R.js";
|
|
6
|
+
|
|
7
|
+
// src/testing/index.ts
|
|
8
|
+
function createTestConfig() {
|
|
9
|
+
return defineConfig({
|
|
10
|
+
storage: { kind: "local" },
|
|
11
|
+
auth: { providers: ["email"] },
|
|
12
|
+
products: {
|
|
13
|
+
test: {
|
|
14
|
+
label: "Test Product",
|
|
15
|
+
sections: {
|
|
16
|
+
hero: defineSection({
|
|
17
|
+
label: "Hero",
|
|
18
|
+
fields: {
|
|
19
|
+
heading: f.text({ label: "Heading", required: true }),
|
|
20
|
+
subtitle: f.text({ label: "Subtitle", multiline: true }),
|
|
21
|
+
badge: f.text({ label: "Badge" })
|
|
22
|
+
}
|
|
23
|
+
}),
|
|
24
|
+
benefits: defineSection({
|
|
25
|
+
label: "Benefits",
|
|
26
|
+
fields: {
|
|
27
|
+
items: f.array(
|
|
28
|
+
f.object(
|
|
29
|
+
{
|
|
30
|
+
title: f.text({ label: "Title" }),
|
|
31
|
+
description: f.text({ label: "Description", multiline: true }),
|
|
32
|
+
icon: f.select({
|
|
33
|
+
label: "Icon",
|
|
34
|
+
options: [
|
|
35
|
+
{ label: "Check", value: "check" },
|
|
36
|
+
{ label: "Star", value: "star" }
|
|
37
|
+
]
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
{ label: "Benefit" }
|
|
41
|
+
),
|
|
42
|
+
{ label: "Benefits", minItems: 1 }
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function createTestContent() {
|
|
52
|
+
return {
|
|
53
|
+
hero: {
|
|
54
|
+
heading: "Test Heading",
|
|
55
|
+
subtitle: "Test Subtitle",
|
|
56
|
+
badge: "New"
|
|
57
|
+
},
|
|
58
|
+
benefits: {
|
|
59
|
+
items: [
|
|
60
|
+
{ title: "Benefit 1", description: "Description 1", icon: "check" },
|
|
61
|
+
{ title: "Benefit 2", description: "Description 2", icon: "star" }
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
createTestConfig,
|
|
68
|
+
createTestContent
|
|
69
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@setzkasten-cms/core",
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"./testing": {
|
|
11
|
+
"import": "./src/testing.ts",
|
|
12
|
+
"types": "./src/testing.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"immer": "^10.2.0",
|
|
21
|
+
"zod": "^3.25.76"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"vitest": "^3.2.4"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { Command } from './command'
|
|
3
|
+
import { createCommandHistory } from './command'
|
|
4
|
+
|
|
5
|
+
function createTestCommand(state: { value: string }, newValue: string): Command {
|
|
6
|
+
const previousValue = state.value
|
|
7
|
+
return {
|
|
8
|
+
execute() {
|
|
9
|
+
state.value = newValue
|
|
10
|
+
},
|
|
11
|
+
undo() {
|
|
12
|
+
state.value = previousValue
|
|
13
|
+
},
|
|
14
|
+
describe() {
|
|
15
|
+
return `Set value to "${newValue}"`
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('CommandHistory', () => {
|
|
21
|
+
it('executes commands', () => {
|
|
22
|
+
const history = createCommandHistory()
|
|
23
|
+
const state = { value: 'initial' }
|
|
24
|
+
|
|
25
|
+
history.execute(createTestCommand(state, 'updated'))
|
|
26
|
+
expect(state.value).toBe('updated')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('undoes commands', () => {
|
|
30
|
+
const history = createCommandHistory()
|
|
31
|
+
const state = { value: 'initial' }
|
|
32
|
+
|
|
33
|
+
history.execute(createTestCommand(state, 'updated'))
|
|
34
|
+
history.undo()
|
|
35
|
+
expect(state.value).toBe('initial')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('redoes commands', () => {
|
|
39
|
+
const history = createCommandHistory()
|
|
40
|
+
const state = { value: 'initial' }
|
|
41
|
+
|
|
42
|
+
history.execute(createTestCommand(state, 'updated'))
|
|
43
|
+
history.undo()
|
|
44
|
+
history.redo()
|
|
45
|
+
expect(state.value).toBe('updated')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('clears redo stack on new command', () => {
|
|
49
|
+
const history = createCommandHistory()
|
|
50
|
+
const state = { value: 'initial' }
|
|
51
|
+
|
|
52
|
+
history.execute(createTestCommand(state, 'first'))
|
|
53
|
+
history.undo()
|
|
54
|
+
expect(history.canRedo()).toBe(true)
|
|
55
|
+
|
|
56
|
+
history.execute(createTestCommand(state, 'second'))
|
|
57
|
+
expect(history.canRedo()).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('reports canUndo/canRedo correctly', () => {
|
|
61
|
+
const history = createCommandHistory()
|
|
62
|
+
const state = { value: 'initial' }
|
|
63
|
+
|
|
64
|
+
expect(history.canUndo()).toBe(false)
|
|
65
|
+
expect(history.canRedo()).toBe(false)
|
|
66
|
+
|
|
67
|
+
history.execute(createTestCommand(state, 'updated'))
|
|
68
|
+
expect(history.canUndo()).toBe(true)
|
|
69
|
+
expect(history.canRedo()).toBe(false)
|
|
70
|
+
|
|
71
|
+
history.undo()
|
|
72
|
+
expect(history.canUndo()).toBe(false)
|
|
73
|
+
expect(history.canRedo()).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('clears history', () => {
|
|
77
|
+
const history = createCommandHistory()
|
|
78
|
+
const state = { value: 'initial' }
|
|
79
|
+
|
|
80
|
+
history.execute(createTestCommand(state, 'updated'))
|
|
81
|
+
history.clear()
|
|
82
|
+
expect(history.canUndo()).toBe(false)
|
|
83
|
+
expect(history.canRedo()).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { FieldPath } from '../fields/field-definition'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Command pattern – all mutations go through commands.
|
|
5
|
+
* This gives undo/redo for free.
|
|
6
|
+
*/
|
|
7
|
+
export interface Command {
|
|
8
|
+
execute(): void
|
|
9
|
+
undo(): void
|
|
10
|
+
describe(): string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Command history – manages undo/redo stacks.
|
|
15
|
+
*/
|
|
16
|
+
export interface CommandHistory {
|
|
17
|
+
execute(command: Command): void
|
|
18
|
+
undo(): void
|
|
19
|
+
redo(): void
|
|
20
|
+
canUndo(): boolean
|
|
21
|
+
canRedo(): boolean
|
|
22
|
+
clear(): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createCommandHistory(): CommandHistory {
|
|
26
|
+
const undoStack: Command[] = []
|
|
27
|
+
const redoStack: Command[] = []
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
execute(command) {
|
|
31
|
+
command.execute()
|
|
32
|
+
undoStack.push(command)
|
|
33
|
+
redoStack.length = 0 // clear redo stack on new action
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
undo() {
|
|
37
|
+
const command = undoStack.pop()
|
|
38
|
+
if (command) {
|
|
39
|
+
command.undo()
|
|
40
|
+
redoStack.push(command)
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
redo() {
|
|
45
|
+
const command = redoStack.pop()
|
|
46
|
+
if (command) {
|
|
47
|
+
command.execute()
|
|
48
|
+
undoStack.push(command)
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
canUndo() {
|
|
53
|
+
return undoStack.length > 0
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
canRedo() {
|
|
57
|
+
return redoStack.length > 0
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
clear() {
|
|
61
|
+
undoStack.length = 0
|
|
62
|
+
redoStack.length = 0
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { FieldPath } from '../fields/field-definition'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Discriminated error union
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export type SetzKastenError =
|
|
8
|
+
| ValidationError
|
|
9
|
+
| ConflictError
|
|
10
|
+
| RateLimitError
|
|
11
|
+
| AuthError
|
|
12
|
+
| NetworkError
|
|
13
|
+
| NotFoundError
|
|
14
|
+
| SerializationError
|
|
15
|
+
|
|
16
|
+
interface BaseError {
|
|
17
|
+
readonly message: string
|
|
18
|
+
readonly cause?: unknown
|
|
19
|
+
readonly timestamp: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ValidationError extends BaseError {
|
|
23
|
+
readonly type: 'validation'
|
|
24
|
+
readonly fieldPath: FieldPath
|
|
25
|
+
readonly rule: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ConflictError extends BaseError {
|
|
29
|
+
readonly type: 'conflict'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RateLimitError extends BaseError {
|
|
33
|
+
readonly type: 'rate-limit'
|
|
34
|
+
readonly retryAfter: number
|
|
35
|
+
readonly remaining: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AuthError extends BaseError {
|
|
39
|
+
readonly type: 'auth'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NetworkError extends BaseError {
|
|
43
|
+
readonly type: 'network'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface NotFoundError extends BaseError {
|
|
47
|
+
readonly type: 'not-found'
|
|
48
|
+
readonly path: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SerializationError extends BaseError {
|
|
52
|
+
readonly type: 'serialization'
|
|
53
|
+
readonly path?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Result type for predictable errors
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export type Result<T, E = SetzKastenError> =
|
|
61
|
+
| { readonly ok: true; readonly value: T }
|
|
62
|
+
| { readonly ok: false; readonly error: E }
|
|
63
|
+
|
|
64
|
+
export function ok<T>(value: T): Result<T, never> {
|
|
65
|
+
return { ok: true, value }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function err<E>(error: E): Result<never, E> {
|
|
69
|
+
return { ok: false, error }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Error factories
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export function validationError(
|
|
77
|
+
fieldPath: FieldPath,
|
|
78
|
+
rule: string,
|
|
79
|
+
message: string,
|
|
80
|
+
): ValidationError {
|
|
81
|
+
return { type: 'validation', fieldPath, rule, message, timestamp: Date.now() }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function conflictError(message: string): ConflictError {
|
|
85
|
+
return { type: 'conflict', message, timestamp: Date.now() }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function rateLimitError(retryAfter: number, remaining: number): RateLimitError {
|
|
89
|
+
return {
|
|
90
|
+
type: 'rate-limit',
|
|
91
|
+
retryAfter,
|
|
92
|
+
remaining,
|
|
93
|
+
message: `Rate limit exceeded. Retry after ${retryAfter}s`,
|
|
94
|
+
timestamp: Date.now(),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function authError(message: string): AuthError {
|
|
99
|
+
return { type: 'auth', message, timestamp: Date.now() }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function networkError(message: string, cause?: unknown): NetworkError {
|
|
103
|
+
return { type: 'network', message, cause, timestamp: Date.now() }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function notFoundError(path: string): NotFoundError {
|
|
107
|
+
return { type: 'not-found', path, message: `Not found: ${path}`, timestamp: Date.now() }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function serializationError(message: string, path?: string): SerializationError {
|
|
111
|
+
return { type: 'serialization', message, path, timestamp: Date.now() }
|
|
112
|
+
}
|