@isardsat/editorial-server 6.19.5 → 6.21.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.
@@ -1,4 +1,4 @@
1
- import type { EditorialData, EditorialDataObjectWithType } from "@isardsat/editorial-common";
1
+ import type { EditorialData, EditorialDataObjectWithType, EditorialUpdateDataItem } from "@isardsat/editorial-common";
2
2
  export declare function createStorage(dataDirectory: string): {
3
3
  getSchema: () => Promise<Record<string, {
4
4
  displayName: string;
@@ -31,7 +31,11 @@ export declare function createStorage(dataDirectory: string): {
31
31
  createdAt: string;
32
32
  updatedAt: string;
33
33
  }>;
34
- updateItem: (item: EditorialDataObjectWithType) => Promise<{
34
+ checkItemExists: ({ type, id }: {
35
+ type: string;
36
+ id: string;
37
+ }) => Promise<boolean>;
38
+ updateItem: (item: EditorialUpdateDataItem) => Promise<{
35
39
  [x: string]: unknown;
36
40
  id: string;
37
41
  isDraft: boolean;
@@ -90,6 +90,10 @@ export function createStorage(dataDirectory) {
90
90
  }
91
91
  return true;
92
92
  }
93
+ async function checkItemExists({ type, id }) {
94
+ const content = await getContent({ production: false });
95
+ return !!content[type]?.[id];
96
+ }
93
97
  async function createItem(item) {
94
98
  const content = await getContent({ production: false });
95
99
  content[item.type] = content[item.type] ?? {};
@@ -100,6 +104,9 @@ export function createStorage(dataDirectory) {
100
104
  return parsedItem;
101
105
  }
102
106
  async function updateItem(item) {
107
+ if (!item.id) {
108
+ throw new Error("Item ID is required for update.");
109
+ }
103
110
  const content = await getContent({ production: false });
104
111
  content[item.type] = content[item.type] ?? {};
105
112
  const oldItem = content[item.type][item.id];
@@ -108,7 +115,17 @@ export function createStorage(dataDirectory) {
108
115
  ...item,
109
116
  updatedAt: new Date().toISOString(),
110
117
  });
111
- content[item.type][item.id] = newItem;
118
+ // Handle ID change: if the ID has changed, we need to delete the old item and create a new one with the new ID
119
+ if (newItem.hasOwnProperty("newId")) {
120
+ delete content[item.type][item.id];
121
+ const newId = item.newId || item.id;
122
+ delete newItem.newId;
123
+ newItem.id = newId;
124
+ content[item.type][newId] = newItem;
125
+ }
126
+ else {
127
+ content[item.type][item.id] = newItem;
128
+ }
112
129
  // TODO: Use superjson to safely encode different types.
113
130
  await writeFileSafe(dataPath, JSON.stringify(content, null, 2));
114
131
  return newItem;
@@ -126,6 +143,7 @@ export function createStorage(dataDirectory) {
126
143
  getLocalisationMessages,
127
144
  saveLocalisationMessages,
128
145
  createItem,
146
+ checkItemExists,
129
147
  updateItem,
130
148
  deleteItem,
131
149
  saveContent,
@@ -0,0 +1,19 @@
1
+ import { type EditorialData, type EditorialDataItem, type EditorialSchema } from "@isardsat/editorial-common";
2
+ import { type Storage } from "../storage.js";
3
+ /**
4
+ * Resolves referenced fields in an item, replacing IDs with full objects.
5
+ * Recursively resolves nested references.
6
+ */
7
+ export declare function resolveReferences(item: EditorialDataItem, schema: EditorialSchema, itemType: string, content: EditorialData, origin: string, resolvedIds?: Set<string>): EditorialDataItem;
8
+ /**
9
+ * Resolves all references in a collection.
10
+ */
11
+ export declare function resolveCollectionReferences(collection: Record<string, EditorialDataItem>, schema: EditorialSchema, itemType: string, content: EditorialData, origin: string): Record<string, EditorialDataItem>;
12
+ export declare function updateOrRemoveReferences(params: {
13
+ schema: any;
14
+ content: Record<string, Record<string, any>>;
15
+ storage: Storage;
16
+ targetType: string;
17
+ oldId: string;
18
+ newId?: string | null;
19
+ }): Promise<void>;
@@ -0,0 +1,135 @@
1
+ import { getChoicesReference, } from "@isardsat/editorial-common";
2
+ import {} from "../storage.js";
3
+ /**
4
+ * Resolves uploaded file paths to full URLs for an item.
5
+ */
6
+ function resolveFileUrls(item, schema, itemType, origin) {
7
+ const resolvedItem = { ...item };
8
+ const itemSchema = schema[itemType];
9
+ if (!itemSchema)
10
+ return resolvedItem;
11
+ for (const [key, value] of Object.entries(resolvedItem)) {
12
+ if (!itemSchema.fields[key]?.isUploadedFile)
13
+ continue;
14
+ if (typeof value !== "string")
15
+ continue;
16
+ if (value.startsWith("http"))
17
+ continue;
18
+ resolvedItem[key] = `${origin}/${value}`;
19
+ }
20
+ return resolvedItem;
21
+ }
22
+ /**
23
+ * Resolves referenced fields in an item, replacing IDs with full objects.
24
+ * Recursively resolves nested references.
25
+ */
26
+ export function resolveReferences(item, schema, itemType, content, origin, resolvedIds = new Set()) {
27
+ const itemIdentifier = `${itemType}:${item.id}`;
28
+ // Prevent circular references
29
+ if (resolvedIds.has(itemIdentifier)) {
30
+ return resolveFileUrls(item, schema, itemType, origin);
31
+ }
32
+ resolvedIds.add(itemIdentifier);
33
+ // First resolve file URLs for the current item
34
+ let resolvedItem = resolveFileUrls(item, schema, itemType, origin);
35
+ const itemSchema = schema[itemType];
36
+ if (!itemSchema)
37
+ return resolvedItem;
38
+ for (const [fieldKey, fieldConfig] of Object.entries(itemSchema.fields)) {
39
+ if (fieldConfig.type !== "select" && fieldConfig.type !== "multiselect") {
40
+ continue;
41
+ }
42
+ const referencedType = getChoicesReference(fieldConfig.choicesFixed);
43
+ if (!referencedType)
44
+ continue;
45
+ const referencedCollection = content[referencedType];
46
+ if (!referencedCollection)
47
+ continue;
48
+ const fieldValue = item[fieldKey];
49
+ if (fieldConfig.type === "select" && typeof fieldValue === "string") {
50
+ // Single reference - replace ID with full object
51
+ const referencedItem = referencedCollection[fieldValue];
52
+ if (referencedItem) {
53
+ // Recursively resolve nested references
54
+ resolvedItem[fieldKey] = resolveReferences(referencedItem, schema, referencedType, content, origin, new Set(resolvedIds));
55
+ }
56
+ }
57
+ else if (fieldConfig.type === "multiselect" &&
58
+ Array.isArray(fieldValue)) {
59
+ // Multiple references - replace IDs with full objects
60
+ resolvedItem[fieldKey] = fieldValue
61
+ .map((id) => {
62
+ const referencedItem = referencedCollection[id];
63
+ if (!referencedItem)
64
+ return null;
65
+ // Recursively resolve nested references
66
+ return resolveReferences(referencedItem, schema, referencedType, content, origin, new Set(resolvedIds));
67
+ })
68
+ .filter(Boolean);
69
+ }
70
+ }
71
+ return resolvedItem;
72
+ }
73
+ /**
74
+ * Resolves all references in a collection.
75
+ */
76
+ export function resolveCollectionReferences(collection, schema, itemType, content, origin) {
77
+ const resolvedCollection = {};
78
+ for (const [itemKey, item] of Object.entries(collection)) {
79
+ resolvedCollection[itemKey] = resolveReferences(item, schema, itemType, content, origin);
80
+ }
81
+ return resolvedCollection;
82
+ }
83
+ export async function updateOrRemoveReferences(params) {
84
+ const { schema, content, storage, targetType, oldId, newId } = params;
85
+ for (const [containerType, containerSchema] of Object.entries(schema)) {
86
+ const fields = containerSchema.fields || {};
87
+ const collection = content[containerType] || {};
88
+ // Find fields in this type that reference the target type
89
+ const referenceFields = Object.entries(fields).filter(([, fieldConfig]) => {
90
+ if (fieldConfig.type !== "select" &&
91
+ fieldConfig.type !== "multiselect") {
92
+ return false;
93
+ }
94
+ return getChoicesReference(fieldConfig.choicesFixed) === targetType;
95
+ });
96
+ if (referenceFields.length === 0)
97
+ continue;
98
+ for (const [itemId, item] of Object.entries(collection)) {
99
+ let changed = false;
100
+ const patchedItem = { ...item };
101
+ for (const [fieldKey, fieldConfig] of referenceFields) {
102
+ if (fieldConfig.type === "select") {
103
+ if (patchedItem[fieldKey] === oldId) {
104
+ patchedItem[fieldKey] = newId ?? null; // rename or remove
105
+ changed = true;
106
+ }
107
+ }
108
+ else if (fieldConfig.type === "multiselect") {
109
+ const value = patchedItem[fieldKey];
110
+ if (Array.isArray(value)) {
111
+ let next;
112
+ if (newId != null) {
113
+ next = value.map((v) => (v === oldId ? newId : v));
114
+ }
115
+ else {
116
+ next = value.filter((v) => v !== oldId); // remove
117
+ }
118
+ if (next.length !== value.length ||
119
+ next.some((v, i) => v !== value[i])) {
120
+ patchedItem[fieldKey] = next;
121
+ changed = true;
122
+ }
123
+ }
124
+ }
125
+ }
126
+ if (changed) {
127
+ await storage.updateItem({
128
+ ...patchedItem,
129
+ type: containerType,
130
+ id: itemId,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,2 @@
1
+ import { type EditorialVersionResponse } from "@isardsat/editorial-common";
2
+ export declare function getCurrentAndLatestVersion(): Promise<EditorialVersionResponse>;
@@ -0,0 +1,22 @@
1
+ import {} from "@isardsat/editorial-common";
2
+ import pkg from "../../../package.json" with { type: "json" };
3
+ const CURRENT_VERSION = pkg.version;
4
+ let latestVersionCache = null;
5
+ let lastChecked = 0;
6
+ export async function getCurrentAndLatestVersion() {
7
+ const now = Date.now();
8
+ if (latestVersionCache && now - lastChecked < 60_000 * 10) {
9
+ // cache 10 min
10
+ return { current: CURRENT_VERSION, latest: latestVersionCache };
11
+ }
12
+ try {
13
+ const res = await fetch("https://registry.npmjs.org/@isardsat/editorial-admin", { headers: { Accept: "application/vnd.npm.install-v1+json" } });
14
+ const data = (await res.json());
15
+ latestVersionCache = data["dist-tags"].latest;
16
+ lastChecked = now;
17
+ return { current: CURRENT_VERSION, latest: latestVersionCache };
18
+ }
19
+ catch {
20
+ throw new Error("Failed to fetch latest version from npm registry");
21
+ }
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -1,9 +1,9 @@
1
1
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
- import { EditorialDataItemSchema, EditorialDataSchema, EditorialDiffResponseSchema, EditorialSchemaSchema, } from "@isardsat/editorial-common";
2
+ import { EditorialDataItemSchema, EditorialDataSchema, EditorialDiffResponseSchema, EditorialSchemaSchema, EditorialUpdateDataItemSchema, } from "@isardsat/editorial-common";
3
3
  import { createCache } from "../lib/cache.js";
4
4
  import { firebaseAuth } from "../lib/middleware/auth.js";
5
5
  import { getChangedFields } from "../lib/utils/diff.js";
6
- import { resolveCollectionReferences, resolveReferences, } from "../lib/utils/resolve.js";
6
+ import { resolveCollectionReferences, resolveReferences, updateOrRemoveReferences, } from "../lib/utils/references.js";
7
7
  import { generateMetaSchema } from "../lib/utils/schema.js";
8
8
  export function createDataRoutes(config, storage) {
9
9
  const app = new OpenAPIHono();
@@ -294,7 +294,7 @@ export function createDataRoutes(config, storage) {
294
294
  app.openapi(createRoute({
295
295
  method: "put",
296
296
  path: "/data/{itemType}/{id}",
297
- summary: "Create or update object data by type and id",
297
+ summary: "Create object data by type and id",
298
298
  request: {
299
299
  params: z.object({
300
300
  itemType: z.string().openapi({
@@ -324,12 +324,22 @@ export function createDataRoutes(config, storage) {
324
324
  },
325
325
  description: "Create a new object",
326
326
  },
327
+ 409: {
328
+ description: "Item with this type and id already exists",
329
+ },
327
330
  },
328
331
  tags: ["Data"],
329
332
  middleware: [firebaseAuth(config.firebase?.projectId || "")],
330
333
  security: [{ bearerAuth: [] }],
331
334
  }), async (c) => {
332
335
  const itemAtts = await c.req.json();
336
+ const itemExists = await storage.checkItemExists({
337
+ type: itemAtts.type,
338
+ id: itemAtts.id,
339
+ });
340
+ if (itemExists) {
341
+ return c.json({ error: "Item with this type and id already exists" }, 409);
342
+ }
333
343
  const newItem = await storage.createItem(itemAtts);
334
344
  cache.invalidateContent();
335
345
  return c.json(newItem);
@@ -352,7 +362,7 @@ export function createDataRoutes(config, storage) {
352
362
  body: {
353
363
  content: {
354
364
  "application/json": {
355
- schema: EditorialDataItemSchema,
365
+ schema: EditorialUpdateDataItemSchema,
356
366
  },
357
367
  },
358
368
  required: true,
@@ -367,15 +377,51 @@ export function createDataRoutes(config, storage) {
367
377
  },
368
378
  description: "Update object",
369
379
  },
380
+ 409: {
381
+ description: "Item with this type and id already exists",
382
+ },
370
383
  },
371
384
  tags: ["Data"],
372
385
  middleware: [firebaseAuth(config.firebase?.projectId || "")],
373
386
  security: [{ bearerAuth: [] }],
374
387
  }), async (c) => {
388
+ const { itemType, id } = c.req.valid("param");
375
389
  const itemAtts = await c.req.json();
376
- const newItem = await storage.updateItem(itemAtts);
390
+ if (itemAtts.newId) {
391
+ const itemWithNewIdExists = await storage.checkItemExists({
392
+ type: itemAtts.type,
393
+ id: itemAtts.newId,
394
+ });
395
+ if (itemWithNewIdExists) {
396
+ return c.json({ error: "Item with this type and this new id already exists" }, 409);
397
+ }
398
+ }
399
+ const oldId = id;
400
+ const newId = itemAtts.newId || itemAtts.id || id;
401
+ const renamed = oldId !== newId;
402
+ // Update renamed item first
403
+ const updatedItem = await storage.updateItem({
404
+ ...itemAtts,
405
+ type: itemType,
406
+ id: oldId,
407
+ });
408
+ // Repair references if needed
409
+ if (renamed) {
410
+ const [schema, content] = await Promise.all([
411
+ storage.getSchema(),
412
+ storage.getContent({ production: false }), // preview/content being edited
413
+ ]);
414
+ await updateOrRemoveReferences({
415
+ schema,
416
+ content,
417
+ storage,
418
+ targetType: itemType,
419
+ oldId,
420
+ newId,
421
+ });
422
+ }
377
423
  cache.invalidateContent();
378
- return c.json(newItem);
424
+ return c.json(updatedItem);
379
425
  });
380
426
  app.openapi(createRoute({
381
427
  method: "delete",
@@ -409,6 +455,19 @@ export function createDataRoutes(config, storage) {
409
455
  }), async (c) => {
410
456
  const { itemType, id } = c.req.valid("param");
411
457
  await storage.deleteItem({ type: itemType, id });
458
+ // Remove references to the deleted item
459
+ const [schema, content] = await Promise.all([
460
+ storage.getSchema(),
461
+ storage.getContent({ production: false }),
462
+ ]);
463
+ await updateOrRemoveReferences({
464
+ schema,
465
+ content,
466
+ storage,
467
+ targetType: itemType,
468
+ oldId: id,
469
+ newId: null,
470
+ });
412
471
  cache.invalidateContent();
413
472
  return c.json(true, 200);
414
473
  });
@@ -492,7 +551,7 @@ export function createDataRoutes(config, storage) {
492
551
  if (singletonKey) {
493
552
  const previewItem = previewCollection[singletonKey];
494
553
  const productionItem = productionCollection[singletonKey];
495
- if (previewItem && !productionItem) {
554
+ if (previewItem && !previewItem.isDraft && !productionItem) {
496
555
  // Singleton exists in preview but not in production = added
497
556
  result.singles[itemType] = {
498
557
  status: "added",
@@ -508,7 +567,8 @@ export function createDataRoutes(config, storage) {
508
567
  production: productionItem,
509
568
  };
510
569
  }
511
- else if (previewItem?.updatedAt !== productionItem?.updatedAt) {
570
+ else if (productionItem &&
571
+ previewItem?.updatedAt !== productionItem?.updatedAt) {
512
572
  // Singleton has different updatedAt = modified
513
573
  const changedFields = getChangedFields(previewItem, productionItem);
514
574
  if (changedFields.length === 0) {
@@ -533,7 +593,7 @@ export function createDataRoutes(config, storage) {
533
593
  // Find added and modified items
534
594
  for (const [id, previewItem] of Object.entries(previewCollection)) {
535
595
  const productionItem = productionCollection[id];
536
- if (!productionItem) {
596
+ if (!previewItem.isDraft && !productionItem) {
537
597
  // Item exists in preview but not in production = added
538
598
  added.push({
539
599
  id,
@@ -541,7 +601,8 @@ export function createDataRoutes(config, storage) {
541
601
  updatedAt: previewItem.updatedAt,
542
602
  });
543
603
  }
544
- else if (previewItem.updatedAt !== productionItem.updatedAt) {
604
+ else if (productionItem &&
605
+ previewItem.updatedAt !== productionItem.updatedAt) {
545
606
  // Item has different updatedAt = modified (not yet published)
546
607
  const changedFields = getChangedFields(previewItem, productionItem);
547
608
  if (changedFields.length === 0) {
@@ -0,0 +1,3 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import type { Hooks } from "../lib/hooks.js";
3
+ export declare function createVersionRoutes(hooks: Hooks): OpenAPIHono<import("hono").Env, {}, "/">;
@@ -0,0 +1,69 @@
1
+ import { OpenAPIHono, z } from "@hono/zod-openapi";
2
+ import { EditorialVersionResponseSchema } from "@isardsat/editorial-common";
3
+ import { getCurrentAndLatestVersion } from "../lib/utils/version.js";
4
+ export function createVersionRoutes(hooks) {
5
+ const app = new OpenAPIHono();
6
+ app.openapi({
7
+ method: "get",
8
+ path: "/version",
9
+ summary: "Get Editorial version",
10
+ responses: {
11
+ 200: {
12
+ content: {
13
+ "application/json": {
14
+ schema: EditorialVersionResponseSchema,
15
+ },
16
+ },
17
+ description: "Editorial version",
18
+ },
19
+ 500: {
20
+ content: {
21
+ "application/json": { schema: z.object({ error: z.string() }) },
22
+ },
23
+ description: "Server error",
24
+ },
25
+ },
26
+ tags: ["Version"],
27
+ }, async (c) => {
28
+ try {
29
+ const { current, latest } = await getCurrentAndLatestVersion();
30
+ return c.json({ current, latest }, 200);
31
+ }
32
+ catch (error) {
33
+ return c.json({ error: "Failed to fetch latest version from npm registry" }, 500);
34
+ }
35
+ });
36
+ app.openapi({
37
+ method: "post",
38
+ path: "/version/upgrade",
39
+ summary: "Trigger Editorial upgrade hook",
40
+ description: "Triggers a placeholder hook to start upgrade flow. Replace hook implementation with real deployment/migration logic.",
41
+ responses: {
42
+ 202: {
43
+ content: {
44
+ "application/json": {
45
+ schema: z.boolean(),
46
+ },
47
+ },
48
+ description: "Upgrade request accepted",
49
+ },
50
+ 500: {
51
+ content: {
52
+ "application/json": { schema: z.object({ error: z.string() }) },
53
+ },
54
+ description: "Server error",
55
+ },
56
+ },
57
+ tags: ["Version"],
58
+ }, async (c) => {
59
+ try {
60
+ await hooks.onUpgrade();
61
+ }
62
+ catch (error) {
63
+ console.error("Error executing onUpgrade script:", error);
64
+ return c.json({ error: "Failed upgrade Editorial version" }, 500);
65
+ }
66
+ return c.json(true, 202);
67
+ });
68
+ return app;
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isardsat/editorial-server",
3
- "version": "6.19.5",
3
+ "version": "6.21.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,8 +15,8 @@
15
15
  "jose": "^6.1.3",
16
16
  "yaml": "^2.8.1",
17
17
  "zod": "^4.1.11",
18
- "@isardsat/editorial-admin": "^6.19.5",
19
- "@isardsat/editorial-common": "^6.19.5"
18
+ "@isardsat/editorial-admin": "^6.21.0",
19
+ "@isardsat/editorial-common": "^6.21.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@tsconfig/node22": "^22.0.0",