@webmaster-droid/server 0.1.0-alpha.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.
@@ -0,0 +1,412 @@
1
+ // src/storage-s3/index.ts
2
+ import {
3
+ DeleteObjectCommand,
4
+ GetObjectCommand,
5
+ ListObjectsV2Command,
6
+ PutObjectCommand,
7
+ S3Client
8
+ } from "@aws-sdk/client-s3";
9
+ import { normalizeCmsDocument } from "@webmaster-droid/contracts";
10
+ function createId(prefix) {
11
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
12
+ }
13
+ function nowIso() {
14
+ return (/* @__PURE__ */ new Date()).toISOString();
15
+ }
16
+ function monthKey(date = /* @__PURE__ */ new Date()) {
17
+ const year = date.getUTCFullYear();
18
+ const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
19
+ return `${year}-${month}`;
20
+ }
21
+ function keySafeTimestamp(date = /* @__PURE__ */ new Date()) {
22
+ return `${date.getTime()}`;
23
+ }
24
+ async function bodyToString(body) {
25
+ if (!body) {
26
+ return "";
27
+ }
28
+ if (typeof body === "string") {
29
+ return body;
30
+ }
31
+ if (typeof body === "object" && body !== null && "transformToString" in body && typeof body.transformToString === "function") {
32
+ return body.transformToString();
33
+ }
34
+ return "";
35
+ }
36
+ function parseIdFromKey(key) {
37
+ const filename = key.split("/").pop() ?? key;
38
+ const parts = filename.split("__");
39
+ if (parts.length !== 2) {
40
+ return filename.replace(/\.json$/, "");
41
+ }
42
+ return parts[1].replace(/\.json$/, "");
43
+ }
44
+ function parseTimestampFromKey(key) {
45
+ const filename = key.split("/").pop() ?? key;
46
+ const parts = filename.split("__");
47
+ if (parts.length !== 2) {
48
+ return (/* @__PURE__ */ new Date(0)).toISOString();
49
+ }
50
+ const encoded = Number(parts[0]);
51
+ const parsed = new Date(encoded);
52
+ if (Number.isNaN(parsed.getTime())) {
53
+ return (/* @__PURE__ */ new Date(0)).toISOString();
54
+ }
55
+ return parsed.toISOString();
56
+ }
57
+ function isRecord(value) {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+ function parseCheckpointEnvelope(value) {
61
+ if (!isRecord(value)) {
62
+ return null;
63
+ }
64
+ const checkpoint = value.checkpoint;
65
+ const content = value.content;
66
+ if (!isRecord(checkpoint) || !isRecord(content)) {
67
+ return null;
68
+ }
69
+ const id = typeof checkpoint.id === "string" ? checkpoint.id : "";
70
+ const createdAt = typeof checkpoint.createdAt === "string" ? checkpoint.createdAt : "";
71
+ const reason = typeof checkpoint.reason === "string" ? checkpoint.reason : "";
72
+ const createdBy = typeof checkpoint.createdBy === "string" ? checkpoint.createdBy : void 0;
73
+ if (!id || !createdAt || !reason) {
74
+ return null;
75
+ }
76
+ return {
77
+ checkpoint: {
78
+ id,
79
+ createdAt,
80
+ createdBy,
81
+ reason
82
+ },
83
+ content
84
+ };
85
+ }
86
+ function mergeLegacyValue(target, incoming) {
87
+ if (!isRecord(target) || !isRecord(incoming)) {
88
+ return incoming;
89
+ }
90
+ for (const [key, value] of Object.entries(incoming)) {
91
+ if (target[key] === void 0) {
92
+ target[key] = value;
93
+ continue;
94
+ }
95
+ target[key] = mergeLegacyValue(target[key], value);
96
+ }
97
+ return target;
98
+ }
99
+ function normalizeLegacyIndexedKeys(root) {
100
+ let changed = false;
101
+ const visit = (node) => {
102
+ if (Array.isArray(node)) {
103
+ for (const item of node) {
104
+ visit(item);
105
+ }
106
+ return;
107
+ }
108
+ if (!isRecord(node)) {
109
+ return;
110
+ }
111
+ const record = node;
112
+ const keys = Object.keys(record);
113
+ for (const key of keys) {
114
+ const match = /^([^\[\]]+)\[(\d+)\]$/.exec(key);
115
+ if (!match) {
116
+ continue;
117
+ }
118
+ const baseKey = match[1];
119
+ const index = Number(match[2]);
120
+ if (Number.isNaN(index)) {
121
+ continue;
122
+ }
123
+ const baseValue = record[baseKey];
124
+ if (baseValue !== void 0 && !Array.isArray(baseValue)) {
125
+ continue;
126
+ }
127
+ const targetArray = record[baseKey] ?? [];
128
+ record[baseKey] = targetArray;
129
+ const legacyValue = record[key];
130
+ const existing = targetArray[index];
131
+ if (existing === void 0) {
132
+ targetArray[index] = legacyValue;
133
+ } else {
134
+ targetArray[index] = mergeLegacyValue(existing, legacyValue);
135
+ }
136
+ delete record[key];
137
+ changed = true;
138
+ }
139
+ for (const value of Object.values(record)) {
140
+ visit(value);
141
+ }
142
+ };
143
+ visit(root);
144
+ return changed;
145
+ }
146
+ var S3CmsStorage = class {
147
+ bucket;
148
+ client;
149
+ prefix;
150
+ constructor(options) {
151
+ this.bucket = options.bucket;
152
+ this.prefix = options.prefix?.replace(/\/$/, "") ?? "cms";
153
+ this.client = options.client ?? new S3Client({
154
+ region: options.region
155
+ });
156
+ }
157
+ async ensureInitialized(seed) {
158
+ const [live, draft] = await Promise.all([
159
+ this.tryGetStage("live"),
160
+ this.tryGetStage("draft")
161
+ ]);
162
+ if (!live) {
163
+ await this.saveStage("live", seed);
164
+ }
165
+ if (!draft) {
166
+ await this.saveStage("draft", seed);
167
+ }
168
+ }
169
+ async getContent(stage) {
170
+ const key = this.stageKey(stage);
171
+ const response = await this.client.send(
172
+ new GetObjectCommand({
173
+ Bucket: this.bucket,
174
+ Key: key
175
+ })
176
+ );
177
+ const text = await bodyToString(response.Body);
178
+ const parsed = JSON.parse(text);
179
+ normalizeLegacyIndexedKeys(parsed);
180
+ return normalizeCmsDocument(parsed);
181
+ }
182
+ async saveDraft(content) {
183
+ await this.saveStage("draft", content);
184
+ }
185
+ async saveLive(content) {
186
+ await this.saveStage("live", content);
187
+ }
188
+ async putPublicAsset(input) {
189
+ const key = input.key.replace(/^\/+/, "");
190
+ if (!key) {
191
+ throw new Error("Public asset key is required.");
192
+ }
193
+ await this.client.send(
194
+ new PutObjectCommand({
195
+ Bucket: this.bucket,
196
+ Key: key,
197
+ Body: input.body,
198
+ ContentType: input.contentType,
199
+ CacheControl: input.cacheControl
200
+ })
201
+ );
202
+ }
203
+ async createCheckpoint(content, input) {
204
+ const createdAt = nowIso();
205
+ const id = createId("cp");
206
+ const key = `${this.prefix}/checkpoints/${keySafeTimestamp()}__${id}.json`;
207
+ const checkpointMeta = {
208
+ id,
209
+ createdAt,
210
+ createdBy: input.createdBy,
211
+ reason: input.reason
212
+ };
213
+ const payload = {
214
+ checkpoint: checkpointMeta,
215
+ content: {
216
+ ...content,
217
+ meta: {
218
+ ...content.meta,
219
+ updatedAt: createdAt,
220
+ updatedBy: input.createdBy,
221
+ sourceCheckpointId: id
222
+ }
223
+ }
224
+ };
225
+ await this.putJson(key, payload);
226
+ return checkpointMeta;
227
+ }
228
+ async deleteCheckpoint(id) {
229
+ const targetId = id.trim();
230
+ if (!targetId) {
231
+ return false;
232
+ }
233
+ const keys = await this.listKeys(`${this.prefix}/checkpoints/`);
234
+ const key = keys.find((candidate) => parseIdFromKey(candidate) === targetId);
235
+ if (!key) {
236
+ return false;
237
+ }
238
+ await this.client.send(
239
+ new DeleteObjectCommand({
240
+ Bucket: this.bucket,
241
+ Key: key
242
+ })
243
+ );
244
+ return true;
245
+ }
246
+ async listCheckpoints() {
247
+ const keys = await this.listKeys(`${this.prefix}/checkpoints/`);
248
+ const items = await Promise.all(
249
+ keys.map(async (key) => {
250
+ const fallback = {
251
+ id: parseIdFromKey(key),
252
+ createdAt: parseTimestampFromKey(key),
253
+ reason: "auto-checkpoint"
254
+ };
255
+ try {
256
+ const text = await this.getText(key);
257
+ const parsed = JSON.parse(text);
258
+ const envelope = parseCheckpointEnvelope(parsed);
259
+ if (!envelope) {
260
+ return fallback;
261
+ }
262
+ return {
263
+ id: envelope.checkpoint.id || fallback.id,
264
+ createdAt: envelope.checkpoint.createdAt || fallback.createdAt,
265
+ createdBy: envelope.checkpoint.createdBy,
266
+ reason: envelope.checkpoint.reason || fallback.reason
267
+ };
268
+ } catch {
269
+ return fallback;
270
+ }
271
+ })
272
+ );
273
+ return items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
274
+ }
275
+ async publishDraft(input) {
276
+ const createdAt = nowIso();
277
+ const id = createId("pub");
278
+ const key = `${this.prefix}/published/${keySafeTimestamp()}__${id}.json`;
279
+ const payload = {
280
+ ...input.content,
281
+ meta: {
282
+ ...input.content.meta,
283
+ updatedAt: createdAt,
284
+ updatedBy: input.createdBy,
285
+ contentVersion: id
286
+ }
287
+ };
288
+ await this.putJson(key, payload);
289
+ return {
290
+ id,
291
+ createdAt,
292
+ createdBy: input.createdBy,
293
+ sourceContentVersion: input.content.meta.contentVersion
294
+ };
295
+ }
296
+ async listPublishedVersions() {
297
+ const keys = await this.listKeys(`${this.prefix}/published/`);
298
+ const items = keys.map((key) => {
299
+ const id = parseIdFromKey(key);
300
+ const createdAt = parseTimestampFromKey(key);
301
+ return {
302
+ id,
303
+ createdAt,
304
+ sourceContentVersion: id
305
+ };
306
+ });
307
+ return items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
308
+ }
309
+ async getSnapshot(input) {
310
+ const folder = input.sourceType === "checkpoint" ? `${this.prefix}/checkpoints/` : `${this.prefix}/published/`;
311
+ const keys = await this.listKeys(folder);
312
+ const key = keys.find((candidate) => parseIdFromKey(candidate) === input.sourceId);
313
+ if (!key) {
314
+ return null;
315
+ }
316
+ const response = await this.client.send(
317
+ new GetObjectCommand({
318
+ Bucket: this.bucket,
319
+ Key: key
320
+ })
321
+ );
322
+ const text = await bodyToString(response.Body);
323
+ const parsed = JSON.parse(text);
324
+ const envelope = parseCheckpointEnvelope(parsed);
325
+ const content = envelope ? envelope.content : parsed;
326
+ normalizeLegacyIndexedKeys(content);
327
+ return normalizeCmsDocument(content);
328
+ }
329
+ async appendEvent(event) {
330
+ const key = `${this.prefix}/events/${monthKey()}.jsonl`;
331
+ const existing = await this.tryGetText(key);
332
+ const line = `${JSON.stringify(event)}
333
+ `;
334
+ const body = `${existing ?? ""}${line}`;
335
+ await this.client.send(
336
+ new PutObjectCommand({
337
+ Bucket: this.bucket,
338
+ Key: key,
339
+ Body: body,
340
+ ContentType: "application/x-ndjson"
341
+ })
342
+ );
343
+ }
344
+ async tryGetStage(stage) {
345
+ try {
346
+ return await this.getContent(stage);
347
+ } catch (error) {
348
+ if (this.isMissingKey(error)) {
349
+ return null;
350
+ }
351
+ throw error;
352
+ }
353
+ }
354
+ async saveStage(stage, content) {
355
+ await this.putJson(this.stageKey(stage), content);
356
+ }
357
+ stageKey(stage) {
358
+ return `${this.prefix}/${stage}/current.json`;
359
+ }
360
+ async putJson(key, value) {
361
+ await this.client.send(
362
+ new PutObjectCommand({
363
+ Bucket: this.bucket,
364
+ Key: key,
365
+ Body: JSON.stringify(value, null, 2),
366
+ ContentType: "application/json"
367
+ })
368
+ );
369
+ }
370
+ async listKeys(prefix) {
371
+ const response = await this.client.send(
372
+ new ListObjectsV2Command({
373
+ Bucket: this.bucket,
374
+ Prefix: prefix
375
+ })
376
+ );
377
+ const keys = (response.Contents ?? []).map((item) => item.Key).filter((key) => Boolean(key));
378
+ return keys;
379
+ }
380
+ async tryGetText(key) {
381
+ try {
382
+ return await this.getText(key);
383
+ } catch (error) {
384
+ if (this.isMissingKey(error)) {
385
+ return null;
386
+ }
387
+ throw error;
388
+ }
389
+ }
390
+ async getText(key) {
391
+ const response = await this.client.send(
392
+ new GetObjectCommand({
393
+ Bucket: this.bucket,
394
+ Key: key
395
+ })
396
+ );
397
+ return bodyToString(response.Body);
398
+ }
399
+ isMissingKey(error) {
400
+ if (typeof error === "object" && error !== null && "name" in error && error.name === "NoSuchKey") {
401
+ return true;
402
+ }
403
+ if (typeof error === "object" && error !== null && "$metadata" in error && typeof error.$metadata?.httpStatusCode === "number") {
404
+ return error.$metadata?.httpStatusCode === 404;
405
+ }
406
+ return false;
407
+ }
408
+ };
409
+
410
+ export {
411
+ S3CmsStorage
412
+ };