@projectdochelp/s3te 3.3.1 → 3.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectdochelp/s3te",
3
- "version": "3.3.1",
3
+ "version": "3.3.3",
4
4
  "description": "CLI, render core, AWS adapter, and testkit for S3TemplateEngine projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,7 @@ import {
17
17
  BatchWriteCommand,
18
18
  DeleteCommand,
19
19
  DynamoDBDocumentClient,
20
+ GetCommand,
20
21
  PutCommand,
21
22
  QueryCommand,
22
23
  ScanCommand
@@ -75,8 +76,37 @@ function localeMatchScore(itemLocale, language, languageLocaleMap) {
75
76
  return 0;
76
77
  }
77
78
 
78
- function matchesRequestedLocale(item, language, languageLocaleMap) {
79
- return localeMatchScore(item?.locale, language, languageLocaleMap) > 0;
79
+ function comparableTimestamp(value) {
80
+ if (typeof value === "number" && Number.isFinite(value)) {
81
+ return value;
82
+ }
83
+
84
+ const timestamp = Date.parse(String(value ?? ""));
85
+ return Number.isFinite(timestamp) ? timestamp : -1;
86
+ }
87
+
88
+ function compareContentFreshness(left, right) {
89
+ const updatedDiff = comparableTimestamp(right.updatedAt) - comparableTimestamp(left.updatedAt);
90
+ if (updatedDiff !== 0) {
91
+ return updatedDiff;
92
+ }
93
+
94
+ const changedDiff = comparableTimestamp(right.lastChangedAt) - comparableTimestamp(left.lastChangedAt);
95
+ if (changedDiff !== 0) {
96
+ return changedDiff;
97
+ }
98
+
99
+ const createdDiff = comparableTimestamp(right.createdAt) - comparableTimestamp(left.createdAt);
100
+ if (createdDiff !== 0) {
101
+ return createdDiff;
102
+ }
103
+
104
+ const versionDiff = Number(right.version ?? -1) - Number(left.version ?? -1);
105
+ if (versionDiff !== 0) {
106
+ return versionDiff;
107
+ }
108
+
109
+ return String(right.id ?? "").localeCompare(String(left.id ?? ""));
80
110
  }
81
111
 
82
112
  function filterItemsByRequestedLocale(items, language, languageLocaleMap) {
@@ -102,7 +132,11 @@ function filterItemsByRequestedLocale(items, language, languageLocaleMap) {
102
132
  }
103
133
 
104
134
  const bestScore = Math.max(...scored.map((entry) => entry.score));
105
- return scored.filter((entry) => entry.score === bestScore).map((entry) => entry.item);
135
+ return scored
136
+ .filter((entry) => entry.score === bestScore)
137
+ .map((entry) => entry.item)
138
+ .sort(compareContentFreshness)
139
+ .slice(0, 1);
106
140
  });
107
141
  }
108
142
 
@@ -143,6 +177,7 @@ export function createAwsClients(region = process.env.AWS_REGION) {
143
177
  invoke: InvokeCommand
144
178
  }),
145
179
  dynamo: wrapCommandClient(dynamoDocumentClient, {
180
+ get: GetCommand,
146
181
  query: QueryCommand,
147
182
  scan: ScanCommand,
148
183
  batchWrite: BatchWriteCommand,
@@ -247,7 +282,7 @@ export function buildCoreConfigFromEnvironment(manifest, environmentName) {
247
282
  debounceSeconds: 60
248
283
  },
249
284
  lambda: {
250
- runtime: "nodejs22.x",
285
+ runtime: "nodejs24.x",
251
286
  architecture: "arm64"
252
287
  }
253
288
  },
@@ -388,14 +423,7 @@ export class DynamoContentRepository {
388
423
  }
389
424
  }).promise();
390
425
  const items = response.Items ?? [];
391
- const candidates = items
392
- .map((item) => ({
393
- item,
394
- score: localeMatchScore(item.locale, language, this.languageLocaleMap)
395
- }))
396
- .filter((entry) => entry.score > 0)
397
- .sort((left, right) => right.score - left.score || String(left.item.id).localeCompare(String(right.item.id)));
398
- return candidates[0]?.item ?? null;
426
+ return filterItemsByRequestedLocale(items, language, this.languageLocaleMap)[0] ?? null;
399
427
  }
400
428
 
401
429
  async query(query, language) {
@@ -1,3 +1,5 @@
1
+ import { gunzipSync } from "node:zlib";
2
+
1
3
  import { createAwsClients, invokeLambdaEvent } from "./common.mjs";
2
4
 
3
5
  function escapeHtml(value) {
@@ -134,10 +136,118 @@ function toSimpleValue(value) {
134
136
  return String(value);
135
137
  }
136
138
 
137
- function normalizeValues(item) {
138
- const valueSource = item.values && typeof item.values === "object"
139
- ? item.values
140
- : (item.data && typeof item.data === "object" && !Array.isArray(item.data) ? item.data : null);
139
+ function getItemRoot(item) {
140
+ return hasNestedEntryEnvelope(item)
141
+ ? item.data
142
+ : item;
143
+ }
144
+
145
+ function hasNestedEntryEnvelope(item) {
146
+ if (!item?.data || typeof item.data !== "object" || Array.isArray(item.data)) {
147
+ return false;
148
+ }
149
+
150
+ return [
151
+ "id",
152
+ "entryId",
153
+ "model",
154
+ "modelId",
155
+ "tenant",
156
+ "tenantId",
157
+ "status",
158
+ "values",
159
+ "createdOn",
160
+ "savedOn",
161
+ "publishedOn",
162
+ "lastPublishedOn"
163
+ ].some((key) => Object.prototype.hasOwnProperty.call(item.data, key));
164
+ }
165
+
166
+ function decodeCompressedValue(value) {
167
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
168
+ return value;
169
+ }
170
+
171
+ if (typeof value.compression !== "string" || typeof value.value !== "string") {
172
+ return value;
173
+ }
174
+
175
+ try {
176
+ const compressed = Buffer.from(value.value, "base64");
177
+ if (value.compression === "gzip") {
178
+ const inflated = gunzipSync(compressed).toString("utf8");
179
+ if (value.isArray) {
180
+ return JSON.parse(inflated);
181
+ }
182
+ return inflated;
183
+ }
184
+ } catch {
185
+ return "";
186
+ }
187
+
188
+ return value.value;
189
+ }
190
+
191
+ function getFieldIdentifiers(field) {
192
+ return [field?.storageId, field?.fieldId].filter(Boolean);
193
+ }
194
+
195
+ function findFieldValue(rawValues, field) {
196
+ for (const identifier of getFieldIdentifiers(field)) {
197
+ if (Object.prototype.hasOwnProperty.call(rawValues, identifier)) {
198
+ return rawValues[identifier];
199
+ }
200
+ }
201
+ return undefined;
202
+ }
203
+
204
+ function normalizeFieldValue(rawValue, field) {
205
+ const decodedValue = decodeCompressedValue(rawValue);
206
+
207
+ if (field?.type === "object") {
208
+ const nestedFields = Array.isArray(field.settings?.fields) ? field.settings.fields : [];
209
+ if (Array.isArray(decodedValue)) {
210
+ return decodedValue.map((entry) => normalizeFieldValue(entry, {
211
+ ...field,
212
+ list: false
213
+ }));
214
+ }
215
+ if (decodedValue && typeof decodedValue === "object") {
216
+ return normalizeMappedValues(decodedValue, nestedFields);
217
+ }
218
+ }
219
+
220
+ if (Array.isArray(decodedValue)) {
221
+ return decodedValue.map((entry) => toSimpleValue(entry));
222
+ }
223
+
224
+ return toSimpleValue(decodedValue);
225
+ }
226
+
227
+ function normalizeMappedValues(rawValues, fields = []) {
228
+ const values = {};
229
+ for (const field of fields) {
230
+ const rawValue = findFieldValue(rawValues, field);
231
+ if (rawValue === undefined) {
232
+ continue;
233
+ }
234
+ values[field.fieldId] = normalizeFieldValue(rawValue, field);
235
+ }
236
+ return values;
237
+ }
238
+
239
+ function normalizeValues(item, modelFields = []) {
240
+ const root = getItemRoot(item);
241
+ const valueSource = root?.values && typeof root.values === "object"
242
+ ? root.values
243
+ : (!hasNestedEntryEnvelope(item) && item?.data && typeof item.data === "object" && !Array.isArray(item.data) ? item.data : null);
244
+
245
+ if (valueSource && Array.isArray(modelFields) && modelFields.length > 0) {
246
+ const mappedValues = normalizeMappedValues(valueSource, modelFields);
247
+ if (Object.keys(mappedValues).length > 0) {
248
+ return mappedValues;
249
+ }
250
+ }
141
251
 
142
252
  if (valueSource) {
143
253
  return Object.fromEntries(Object.entries(valueSource).map(([key, value]) => [key, toSimpleValue(value)]));
@@ -178,44 +288,62 @@ function normalizeValues(item) {
178
288
  }
179
289
 
180
290
  function isPublished(item) {
181
- return item.status === "published"
182
- || item.published === true
183
- || item.isPublished === true
184
- || item.publishedOn != null;
291
+ const root = getItemRoot(item);
292
+ return root.status === "published"
293
+ || root.published === true
294
+ || root.isPublished === true
295
+ || root.publishedOn != null
296
+ || root.firstPublishedOn != null
297
+ || root.lastPublishedOn != null;
185
298
  }
186
299
 
187
300
  function extractWebinyLocale(item) {
188
- return item.locale
189
- ?? item.localeCode
190
- ?? item.i18n?.locale?.code
191
- ?? item.i18n?.localeCode
301
+ const root = getItemRoot(item);
302
+ return root.locale
303
+ ?? root.localeCode
304
+ ?? root.i18n?.locale?.code
305
+ ?? root.i18n?.localeCode
192
306
  ?? null;
193
307
  }
194
308
 
195
309
  function extractWebinyTenant(item) {
196
- return item.tenant
197
- ?? item.tenantId
198
- ?? item.createdBy?.tenant
310
+ const root = getItemRoot(item);
311
+ return root.tenant
312
+ ?? root.tenantId
313
+ ?? root.createdBy?.tenant
199
314
  ?? null;
200
315
  }
201
316
 
202
- export function normalizeContentItem(item) {
203
- const model = item.model
204
- ?? item.modelId
205
- ?? item.__typename
206
- ?? item.contentModel?.modelId
317
+ function normalizeMirrorLocale(value) {
318
+ return value == null ? "" : String(value).trim().toLowerCase();
319
+ }
320
+
321
+ function isSameMirroredContentIdentity(existingItem, contentItem) {
322
+ return String(existingItem.contentId ?? "") === String(contentItem.contentId ?? "")
323
+ && String(existingItem.model ?? "") === String(contentItem.model ?? "")
324
+ && String(existingItem.tenant ?? "") === String(contentItem.tenant ?? "")
325
+ && normalizeMirrorLocale(existingItem.locale) === normalizeMirrorLocale(contentItem.locale);
326
+ }
327
+
328
+ export function normalizeContentItem(item, options = {}) {
329
+ const root = getItemRoot(item);
330
+ const values = normalizeValues(item, options.modelFields);
331
+ const model = root.model
332
+ ?? root.modelId
333
+ ?? root.__typename
334
+ ?? root.contentModel?.modelId
207
335
  ?? null;
208
336
  return {
209
- id: item.id,
210
- contentId: item.contentId ?? item.contentid ?? item.entryId ?? item.id,
337
+ id: root.id ?? item.id,
338
+ contentId: values.contentId ?? values.contentid ?? root.contentId ?? root.contentid ?? root.entryId ?? root.id ?? item.id,
211
339
  model,
212
340
  locale: extractWebinyLocale(item) ?? undefined,
213
341
  tenant: extractWebinyTenant(item) ?? undefined,
214
- values: normalizeValues(item),
215
- createdAt: item.createdAt ?? item.createdOn,
216
- updatedAt: item.updatedAt ?? item.savedOn ?? item.publishedOn,
217
- version: item._version ?? item.version,
218
- lastChangedAt: item._lastChangedAt ?? item.lastChangedAt
342
+ values,
343
+ createdAt: root.createdAt ?? root.createdOn,
344
+ updatedAt: root.updatedAt ?? root.savedOn ?? root.publishedOn ?? root.lastPublishedOn,
345
+ version: root._version ?? root.version,
346
+ lastChangedAt: root._lastChangedAt ?? root.lastChangedAt
219
347
  };
220
348
  }
221
349
 
@@ -224,20 +352,87 @@ export function matchesConfiguredTenant(item, configuredTenant) {
224
352
  return true;
225
353
  }
226
354
 
227
- const tenant = item.tenant ?? item.tenantId ?? item.createdBy?.tenant ?? null;
355
+ const root = getItemRoot(item);
356
+ const tenant = root.tenant ?? root.tenantId ?? root.createdBy?.tenant ?? null;
228
357
  return tenant != null && String(tenant) === String(configuredTenant);
229
358
  }
230
359
 
360
+ async function loadModelFields(clients, sourceTableName, tenant, modelId, cache) {
361
+ if (!sourceTableName || !tenant || !modelId) {
362
+ return [];
363
+ }
364
+
365
+ const cacheKey = `${tenant}#${modelId}`;
366
+ if (cache.has(cacheKey)) {
367
+ return cache.get(cacheKey);
368
+ }
369
+
370
+ const response = await clients.dynamo.get({
371
+ TableName: sourceTableName,
372
+ Key: {
373
+ PK: `T#${tenant}#CMS#CM`,
374
+ SK: modelId
375
+ }
376
+ }).promise();
377
+ const model = response.Item?.data ?? response.Item ?? null;
378
+ const fields = Array.isArray(model?.fields) ? model.fields : [];
379
+ cache.set(cacheKey, fields);
380
+ return fields;
381
+ }
382
+
383
+ async function listMirroredContentItems(clients, tableName, indexName, contentId) {
384
+ const items = [];
385
+ let lastEvaluatedKey;
386
+
387
+ do {
388
+ const response = await clients.dynamo.query({
389
+ TableName: tableName,
390
+ IndexName: indexName,
391
+ KeyConditionExpression: "contentId = :contentId",
392
+ ExpressionAttributeValues: {
393
+ ":contentId": contentId
394
+ },
395
+ ExclusiveStartKey: lastEvaluatedKey
396
+ }).promise();
397
+ items.push(...(response.Items ?? []));
398
+ lastEvaluatedKey = response.LastEvaluatedKey;
399
+ } while (lastEvaluatedKey);
400
+
401
+ return items;
402
+ }
403
+
404
+ async function removeMirroredContentRevisions(clients, tableName, indexName, contentItem, excludeId = null) {
405
+ const mirroredItems = await listMirroredContentItems(clients, tableName, indexName, contentItem.contentId);
406
+ const removals = mirroredItems.filter((existingItem) => (
407
+ isSameMirroredContentIdentity(existingItem, contentItem)
408
+ && existingItem.id !== excludeId
409
+ ));
410
+
411
+ for (const existingItem of removals) {
412
+ await clients.dynamo.delete({
413
+ TableName: tableName,
414
+ Key: {
415
+ id: existingItem.id
416
+ }
417
+ }).promise();
418
+ }
419
+
420
+ return removals.length;
421
+ }
422
+
231
423
  export async function handler(event) {
232
424
  const clients = createAwsClients();
233
425
  const tableName = process.env.S3TE_CONTENT_TABLE;
234
426
  const renderWorkerName = process.env.S3TE_RENDER_WORKER_NAME;
235
427
  const environmentName = process.env.S3TE_ENVIRONMENT;
428
+ const sourceTableName = process.env.S3TE_WEBINY_SOURCE_TABLE;
429
+ const contentIndexName = process.env.S3TE_CONTENT_ID_INDEX_NAME ?? "contentid";
236
430
  const configuredTenant = String(process.env.S3TE_WEBINY_TENANT ?? "").trim();
237
431
  const relevantModels = new Set(String(process.env.S3TE_RELEVANT_MODELS ?? "")
238
432
  .split(",")
239
433
  .map((entry) => entry.trim())
240
434
  .filter(Boolean));
435
+ const modelFieldCache = new Map();
241
436
 
242
437
  let mirrored = 0;
243
438
  let deleted = 0;
@@ -253,20 +448,22 @@ export async function handler(event) {
253
448
  continue;
254
449
  }
255
450
 
256
- const contentItem = normalizeContentItem(item);
451
+ const itemRoot = getItemRoot(item);
452
+ const modelFields = await loadModelFields(
453
+ clients,
454
+ sourceTableName,
455
+ extractWebinyTenant(item),
456
+ itemRoot?.modelId ?? itemRoot?.model,
457
+ modelFieldCache
458
+ );
459
+ const contentItem = normalizeContentItem(item, { modelFields });
257
460
  if (!contentItem.id || !contentItem.model || (relevantModels.size > 0 && !relevantModels.has(contentItem.model))) {
258
461
  continue;
259
462
  }
260
463
 
261
464
  const shouldDelete = record.eventName === "REMOVE" || !isPublished(item);
262
465
  if (shouldDelete) {
263
- await clients.dynamo.delete({
264
- TableName: tableName,
265
- Key: {
266
- id: contentItem.id
267
- }
268
- }).promise();
269
- deleted += 1;
466
+ deleted += await removeMirroredContentRevisions(clients, tableName, contentIndexName, contentItem);
270
467
  await invokeLambdaEvent(clients.lambda, renderWorkerName, {
271
468
  type: "content-item",
272
469
  action: "delete",
@@ -278,6 +475,7 @@ export async function handler(event) {
278
475
  continue;
279
476
  }
280
477
 
478
+ await removeMirroredContentRevisions(clients, tableName, contentIndexName, contentItem, contentItem.id);
281
479
  await clients.dynamo.put({
282
480
  TableName: tableName,
283
481
  Item: contentItem
@@ -631,7 +631,9 @@ export function buildWebinyCloudFormationTemplate({ config, environment }) {
631
631
  Variables: {
632
632
  S3TE_ENVIRONMENT: environment,
633
633
  S3TE_CONTENT_TABLE: runtimeConfig.tables.content,
634
+ S3TE_CONTENT_ID_INDEX_NAME: config.aws.contentStore.contentIdIndexName,
634
635
  S3TE_RELEVANT_MODELS: runtimeConfig.integrations.webiny.relevantModels.join(","),
636
+ S3TE_WEBINY_SOURCE_TABLE: runtimeConfig.integrations.webiny.sourceTableName,
635
637
  S3TE_WEBINY_TENANT: runtimeConfig.integrations.webiny.tenant ?? "",
636
638
  S3TE_RENDER_WORKER_NAME: functionNames.renderWorker
637
639
  }
@@ -291,7 +291,7 @@ function schemaTemplate() {
291
291
  properties: {
292
292
  runtime: {
293
293
  type: "string",
294
- enum: ["nodejs22.x"]
294
+ enum: ["nodejs24.x"]
295
295
  },
296
296
  architecture: {
297
297
  type: "string",
@@ -277,7 +277,7 @@ export function resolveProjectConfig(projectConfig) {
277
277
  debounceSeconds: projectConfig.aws?.invalidationStore?.debounceSeconds ?? 60
278
278
  },
279
279
  lambda: {
280
- runtime: projectConfig.aws?.lambda?.runtime ?? "nodejs22.x",
280
+ runtime: projectConfig.aws?.lambda?.runtime ?? "nodejs24.x",
281
281
  architecture: projectConfig.aws?.lambda?.architecture ?? "arm64"
282
282
  }
283
283
  };