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