@plusscommunities/pluss-core-aws 2.0.25-beta.7 → 2.0.25-beta.9

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.
@@ -144,6 +144,23 @@ const fanOutHqSource = async (hqSourceRow, tableName) => {
144
144
  if (targets.length === 0) {
145
145
  return [];
146
146
  }
147
+ return writeCopiesForTargets(hqSourceRow, targets, tableName);
148
+ };
149
+
150
+ /**
151
+ * Write one community-copy row per site in `targetSites` (all of the source's
152
+ * PublishedToSites on INSERT fan-out, or just the newly-added subset on a
153
+ * MODIFY target-set change). Shared by fanOutHqSource and syncHqTargets. The
154
+ * deterministic RowId makes each write idempotent AND makes re-adding a
155
+ * previously-removed (soft-deleted) site a lossless resurrection — the PUT
156
+ * overwrites the row and drops the Deleted flag.
157
+ *
158
+ * @param {object} hqSourceRow — the HQ-source row.
159
+ * @param {string[]} targetSites — sites to write copies for.
160
+ * @param {string} tableName — entity's table name.
161
+ * @returns {Promise<object[]>} the copy rows written.
162
+ */
163
+ const writeCopiesForTargets = (hqSourceRow, targetSites, tableName) => {
147
164
  const copyId = `hqcopy-${hqSourceRow.Id}`;
148
165
  // Preserve the HQ-source row's own UnixTimestamp on each copy rather than
149
166
  // stamping "now". For an immediate publish the source timestamp already IS
@@ -163,7 +180,7 @@ const fanOutHqSource = async (hqSourceRow, tableName) => {
163
180
  const now = moment.utc().valueOf();
164
181
  const publishTs = hqSourceRow.UnixTimestamp || now;
165
182
 
166
- const writes = targets.map((targetSiteId) => {
183
+ const writes = targetSites.map((targetSiteId) => {
167
184
  // Spread + override pattern: keep all content fields, swap identity
168
185
  // fields, drop HQ-only fields. Spread is shallow — nested objects
169
186
  // (Author, etc.) share references with the source row, which is fine
@@ -192,6 +209,63 @@ const fanOutHqSource = async (hqSourceRow, tableName) => {
192
209
  return Promise.all(writes);
193
210
  };
194
211
 
212
+ /**
213
+ * Reconcile community copies when an HQ-source MODIFY changes PublishedToSites:
214
+ * fan out copies for newly-ADDED sites, soft-delete copies for REMOVED sites.
215
+ * Content propagation to surviving copies is propagateHqEdit's job; this only
216
+ * reconciles the target SET. Re-adding a removed site resurrects its copy
217
+ * losslessly (deterministic RowId + PUT — see writeCopiesForTargets).
218
+ *
219
+ * MUST run sequentially relative to propagateHqEdit (never concurrently): both
220
+ * use editRef (get-merge-put), and two concurrent writes to the same removed-
221
+ * copy RowId would race (one clobbering the other's Deleted flag).
222
+ *
223
+ * @param {object} prevRow — OldImage of the HQ-source row.
224
+ * @param {object} nextRow — NewImage of the HQ-source row.
225
+ * @param {string} tableName — entity's table name.
226
+ * @param {object} [options]
227
+ * @param {string} [options.indexName="NewsletterEntriesHqSourceIdIndex"]
228
+ * @returns {Promise<{added: number, removed: number}>}
229
+ */
230
+ const syncHqTargets = async (prevRow, nextRow, tableName, options = {}) => {
231
+ const indexName = options.indexName || "NewsletterEntriesHqSourceIdIndex";
232
+ if (!nextRow || nextRow.Site !== "hq" || nextRow.Deleted) {
233
+ return { added: 0, removed: 0 };
234
+ }
235
+ const prev = Array.isArray(prevRow?.PublishedToSites)
236
+ ? prevRow.PublishedToSites
237
+ : [];
238
+ const next = Array.isArray(nextRow.PublishedToSites)
239
+ ? nextRow.PublishedToSites
240
+ : [];
241
+ const added = next.filter((s) => !prev.includes(s));
242
+ const removed = prev.filter((s) => !next.includes(s));
243
+
244
+ if (added.length > 0) {
245
+ await writeCopiesForTargets(nextRow, added, tableName);
246
+ }
247
+ if (removed.length > 0) {
248
+ const copies = await findCopiesByHqSourceId(
249
+ nextRow.Id,
250
+ tableName,
251
+ indexName,
252
+ );
253
+ const toRemove = copies.filter((c) => removed.includes(c.Site));
254
+ // Per-call fresh payload — editRef sync-mutates updates[keyCol]; sharing
255
+ // across Promise.all(map) would leak RowId between iterations (same
256
+ // rationale as propagateHqEdit / cascadeHqRetract).
257
+ await Promise.all(
258
+ toRemove.map((c) =>
259
+ editRef(tableName, "RowId", c.RowId, {
260
+ Deleted: true,
261
+ Changed: moment.utc().unix(),
262
+ }),
263
+ ),
264
+ );
265
+ }
266
+ return { added: added.length, removed: removed.length };
267
+ };
268
+
195
269
  /**
196
270
  * Propagate an HQ-source MODIFY to every community copy via the GSI lookup.
197
271
  * Skips the retract case (where `Deleted: true`) — that flows through
@@ -326,8 +400,8 @@ const cascadeHqRetract = async (hqSourceRow, tableName, options = {}) => {
326
400
  * @param {string} templateId — the `Id` of the contentLibrary row.
327
401
  * @param {string} contentLibraryTable — entity-agnostic; caller passes the
328
402
  * table name (without prefix).
329
- * @returns {Promise<boolean>} `true` if increment applied; `false` if table
330
- * doesn't exist (graceful skip).
403
+ * @returns {Promise<boolean>} `true` if increment applied; `false` on graceful
404
+ * skip (table not shipped yet, or no template with that Id exists).
331
405
  */
332
406
  const incrementTemplateUseCount = async (templateId, contentLibraryTable) => {
333
407
  if (!templateId || !contentLibraryTable) {
@@ -336,11 +410,16 @@ const incrementTemplateUseCount = async (templateId, contentLibraryTable) => {
336
410
  const params = {
337
411
  TableName: `${process.env.tablePrefix}${contentLibraryTable}`,
338
412
  Key: { Id: templateId },
339
- UpdateExpression:
340
- "ADD UseCount :inc SET LastUsedTimestamp = :now",
413
+ UpdateExpression: "ADD UseCount :inc SET LastUsedTimestamp = :now",
414
+ // Guard against minting a phantom row: ADD on a missing key would CREATE
415
+ // it with UseCount=1. Only increment a template that actually exists; a
416
+ // stale/bogus TemplateId is skipped (ConditionalCheckFailed below).
417
+ ConditionExpression: "attribute_exists(Id)",
341
418
  ExpressionAttributeValues: {
342
419
  ":inc": 1,
343
- ":now": moment.utc().unix(),
420
+ // epoch ms — the contentLibrary timestamp convention (matches
421
+ // UpdatedAt). Was .unix() (seconds); reconciled to .valueOf().
422
+ ":now": moment().utc().valueOf(),
344
423
  },
345
424
  };
346
425
  try {
@@ -358,6 +437,12 @@ const incrementTemplateUseCount = async (templateId, contentLibraryTable) => {
358
437
  );
359
438
  return false;
360
439
  }
440
+ if (error?.code === "ConditionalCheckFailedException") {
441
+ // TemplateId references no existing template — skip rather than
442
+ // create a phantom row. Not an error; continue the stream handler.
443
+ log("incrementTemplateUseCount", "NoSuchTemplate", { templateId });
444
+ return false;
445
+ }
361
446
  // Anything else is genuinely unexpected — surface it.
362
447
  log("incrementTemplateUseCount", "Error", error);
363
448
  throw error;
@@ -370,6 +455,8 @@ module.exports = {
370
455
  // Story B
371
456
  findCopiesByHqSourceId,
372
457
  fanOutHqSource,
458
+ writeCopiesForTargets,
459
+ syncHqTargets,
373
460
  propagateHqEdit,
374
461
  cascadeHqRetract,
375
462
  incrementTemplateUseCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plusscommunities/pluss-core-aws",
3
- "version": "2.0.25-beta.7",
3
+ "version": "2.0.25-beta.9",
4
4
  "description": "Core extension package for Pluss Communities platform",
5
5
  "scripts": {
6
6
  "betapatch": "npm version prepatch --preid=beta",