@plusscommunities/pluss-core-aws 2.0.25-beta.0 → 2.0.25-beta.1

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.
@@ -11,13 +11,42 @@
11
11
  * Story coverage:
12
12
  * - **Story A (PC-1443)** — `isHqLocked` (lock guard for community-copy mutations).
13
13
  * - **Story B (PC-1444)** — adds `findCopiesByHqSourceId`, `fanOutHqSource`,
14
- * `propagateHqEdit`, `cascadeHqRetract`, and `incrementTemplateUseCount`
15
- * (these will appear as additional exports here).
14
+ * `propagateHqEdit`, `cascadeHqRetract`, and `incrementTemplateUseCount`.
16
15
  *
17
- * If this file grows beyond ~200 lines or develops sub-themes, refactor to a
16
+ * If this file grows beyond ~300 lines or develops sub-themes, refactor to a
18
17
  * `hqPublishing/` subdirectory with `index.js` re-exports.
19
18
  */
20
19
 
20
+ const AWS = require("aws-sdk");
21
+ const moment = require("moment");
22
+ const indexQuery = require("../db/common/indexQuery");
23
+ const updateRef = require("../db/common/updateRef");
24
+ const editRef = require("../db/common/editRef");
25
+
26
+ // Raw DocumentClient for atomic-increment operations that editRef can't do
27
+ // safely under concurrent stream-handler invocations.
28
+ const dynamoDb = new AWS.DynamoDB.DocumentClient({ convertEmptyValues: true });
29
+
30
+ /**
31
+ * Fields owned by each community copy independently — never overwritten on
32
+ * propagation from the HQ-source row. Other entities can pass a wider list via
33
+ * `propagateHqEdit`'s `protectedFields` param if their schema has more identity
34
+ * columns.
35
+ */
36
+ const DEFAULT_PROTECTED_FIELDS = [
37
+ "Id",
38
+ "RowId",
39
+ "Site",
40
+ "HqSourceId",
41
+ "PublishedToSites", // HQ-only field; copies don't carry the multi-site list
42
+ "UnsentNotification", // each copy's notification state is independent
43
+ "Deleted", // cascadeHqRetract is the dedicated path; propagateHqEdit must not touch this
44
+ ];
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Story A — lock guard
48
+ // ---------------------------------------------------------------------------
49
+
21
50
  /**
22
51
  * Returns `true` when the entry is a community copy of an HQ-published post —
23
52
  * i.e. its `HqSourceId` is **truthy**.
@@ -40,6 +69,269 @@
40
69
  */
41
70
  const isHqLocked = (entry) => Boolean(entry?.HqSourceId);
42
71
 
72
+ // ---------------------------------------------------------------------------
73
+ // Story B — fan-out, propagation, retract cascade, UseCount
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Query the GSI on `HqSourceId` to locate every community copy of a given HQ
78
+ * source row. Returns the full row data (the GSI uses `Projection: ALL` so
79
+ * follow-up reads aren't needed).
80
+ *
81
+ * @param {string} hqSourceId — the `Id` of the HQ-source row.
82
+ * @param {string} tableName — entity's table name (without prefix; helper
83
+ * prepends `process.env.tablePrefix`).
84
+ * @param {string} [indexName="NewsletterEntriesHqSourceIdIndex"] — GSI name.
85
+ * Other entities pass their own index name.
86
+ * @returns {Promise<object[]>} array of copy rows; `[]` if no copies exist
87
+ * (HQ source has no fan-out yet).
88
+ */
89
+ const findCopiesByHqSourceId = async (
90
+ hqSourceId,
91
+ tableName,
92
+ indexName = "NewsletterEntriesHqSourceIdIndex",
93
+ ) => {
94
+ if (!hqSourceId) {
95
+ return [];
96
+ }
97
+ const result = await indexQuery(tableName, {
98
+ IndexName: indexName,
99
+ KeyConditionExpression: "HqSourceId = :sid",
100
+ ExpressionAttributeValues: { ":sid": hqSourceId },
101
+ });
102
+ return result?.Items || [];
103
+ };
104
+
105
+ /**
106
+ * Fan out one community-copy row per target site listed in
107
+ * `hqSourceRow.PublishedToSites`. Each copy carries:
108
+ * - **Deterministic RowId** `${targetSiteId}_hqcopy-${hqSourceRow.Id}` so
109
+ * re-fires of the stream INSERT event become idempotent put-with-same-key
110
+ * operations (no duplicates).
111
+ * - `Id` derived from `hqcopy-${hqSourceRow.Id}` (mirrors the deterministic
112
+ * RowId; stable across stream re-fires).
113
+ * - `Site: targetSiteId` and `HqSourceId: hqSourceRow.Id` (the lock-guard
114
+ * anchor).
115
+ * - Full denormalised content from the HQ-source row, MINUS HQ-only fields
116
+ * (`PublishedToSites`) and identity fields the copy owns independently.
117
+ * - Fresh `UnixTimestamp` per copy so each community's feed sees the post as
118
+ * "now-published" rather than the HQ-source's creation time.
119
+ *
120
+ * Caller (the entity's stream handler) is responsible for SKIPPING
121
+ * `publishNotifications` on the HQ-source INSERT — notifications fire natively
122
+ * on each copy's own INSERT stream event downstream (per AC1.4 / U1.2).
123
+ *
124
+ * @param {object} hqSourceRow — the HQ-source row from the stream NewImage.
125
+ * @param {string} tableName — entity's table name.
126
+ * @returns {Promise<object[]>} array of the copy rows written.
127
+ */
128
+ const fanOutHqSource = async (hqSourceRow, tableName) => {
129
+ if (!hqSourceRow || hqSourceRow.Site !== "hq") {
130
+ return [];
131
+ }
132
+ const targets = Array.isArray(hqSourceRow.PublishedToSites)
133
+ ? hqSourceRow.PublishedToSites
134
+ : [];
135
+ if (targets.length === 0) {
136
+ return [];
137
+ }
138
+ const copyId = `hqcopy-${hqSourceRow.Id}`;
139
+ const now = moment.utc().unix();
140
+
141
+ const writes = targets.map((targetSiteId) => {
142
+ // Spread + override pattern: keep all content fields, swap identity
143
+ // fields, drop HQ-only fields. Spread is shallow — nested objects
144
+ // (Author, etc.) share references with the source row, which is fine
145
+ // because we're writing to a separate row and DDB serialises by value.
146
+ const copy = { ...hqSourceRow };
147
+ copy.Id = copyId;
148
+ copy.Site = targetSiteId;
149
+ copy.RowId = `${targetSiteId}_${copyId}`;
150
+ copy.HqSourceId = hqSourceRow.Id;
151
+ copy.UnixTimestamp = now;
152
+ copy.UnixTimestampReverse = Number.MAX_SAFE_INTEGER - now;
153
+ // Each copy's own INSERT fires publishNotifications via the existing
154
+ // pipeline. Setting UnsentNotification: "X" matches the convention
155
+ // used by addNewsletterEntry when caller requested a notification.
156
+ copy.UnsentNotification = hqSourceRow.UnsentNotification || "X";
157
+ // HQ-only fields don't travel:
158
+ delete copy.PublishedToSites;
159
+ delete copy.Deleted; // copies start undeleted; retract cascade is separate
160
+ return updateRef(tableName, copy).then(() => copy);
161
+ });
162
+ return Promise.all(writes);
163
+ };
164
+
165
+ /**
166
+ * Propagate an HQ-source MODIFY to every community copy via the GSI lookup.
167
+ * Skips the retract case (where `Deleted: true`) — that flows through
168
+ * `cascadeHqRetract` instead.
169
+ *
170
+ * Identity fields the copy owns independently (Id, RowId, Site, HqSourceId,
171
+ * PublishedToSites, UnsentNotification, Deleted) are NEVER overwritten. Other
172
+ * entities can extend the protected list via `options.protectedFields`.
173
+ *
174
+ * Latency target per §3.1 AC1.5: end-to-end within 30s. Each copy gets a
175
+ * separate `editRef` call (get-merge-put); they run in parallel.
176
+ *
177
+ * @param {object} hqSourceRow — NewImage of the HQ-source row from the stream.
178
+ * @param {object} _prevRow — OldImage (currently unused; reserved for future
179
+ * delta-diff optimisation when we want to skip propagation on no-op changes).
180
+ * @param {string} tableName — entity's table name.
181
+ * @param {object} [options]
182
+ * @param {string} [options.indexName="NewsletterEntriesHqSourceIdIndex"]
183
+ * @param {string[]} [options.protectedFields=DEFAULT_PROTECTED_FIELDS]
184
+ * @returns {Promise<number>} count of copies updated.
185
+ */
186
+ const propagateHqEdit = async (
187
+ hqSourceRow,
188
+ _prevRow,
189
+ tableName,
190
+ options = {},
191
+ ) => {
192
+ const protectedFields =
193
+ options.protectedFields || DEFAULT_PROTECTED_FIELDS;
194
+ const indexName =
195
+ options.indexName || "NewsletterEntriesHqSourceIdIndex";
196
+
197
+ if (!hqSourceRow || hqSourceRow.Site !== "hq" || hqSourceRow.Deleted) {
198
+ return 0;
199
+ }
200
+
201
+ const copies = await findCopiesByHqSourceId(
202
+ hqSourceRow.Id,
203
+ tableName,
204
+ indexName,
205
+ );
206
+ if (copies.length === 0) {
207
+ return 0;
208
+ }
209
+
210
+ // Build the propagation payload: everything from the HQ-source row EXCEPT
211
+ // protected identity fields.
212
+ const propagated = { ...hqSourceRow };
213
+ for (const field of protectedFields) {
214
+ delete propagated[field];
215
+ }
216
+ propagated.Changed = moment.utc().unix();
217
+
218
+ await Promise.all(
219
+ copies.map((copy) =>
220
+ editRef(tableName, "RowId", copy.RowId, propagated),
221
+ ),
222
+ );
223
+ return copies.length;
224
+ };
225
+
226
+ /**
227
+ * Cascade `Deleted: true` to every community copy when the HQ-source row is
228
+ * retracted (`Deleted: true` set on the HQ-source MODIFY). Per §12.2, data is
229
+ * preserved at the storage layer — only the `Deleted` flag flips; no rows are
230
+ * removed. Un-retract UI is out of scope this iteration.
231
+ *
232
+ * @param {object} hqSourceRow — NewImage of the retracted HQ-source row.
233
+ * @param {string} tableName — entity's table name.
234
+ * @param {object} [options]
235
+ * @param {string} [options.indexName="NewsletterEntriesHqSourceIdIndex"]
236
+ * @returns {Promise<number>} count of copies marked deleted.
237
+ */
238
+ const cascadeHqRetract = async (hqSourceRow, tableName, options = {}) => {
239
+ const indexName =
240
+ options.indexName || "NewsletterEntriesHqSourceIdIndex";
241
+
242
+ if (!hqSourceRow || hqSourceRow.Site !== "hq" || !hqSourceRow.Deleted) {
243
+ return 0;
244
+ }
245
+
246
+ const copies = await findCopiesByHqSourceId(
247
+ hqSourceRow.Id,
248
+ tableName,
249
+ indexName,
250
+ );
251
+ if (copies.length === 0) {
252
+ return 0;
253
+ }
254
+
255
+ const deletionUpdate = {
256
+ Deleted: true,
257
+ Changed: moment.utc().unix(),
258
+ };
259
+ await Promise.all(
260
+ copies.map((copy) =>
261
+ editRef(tableName, "RowId", copy.RowId, deletionUpdate),
262
+ ),
263
+ );
264
+ return copies.length;
265
+ };
266
+
267
+ /**
268
+ * Atomically increment `UseCount` on a `contentLibrary` template row when a
269
+ * new entity row is created from that template (`TemplateId !== null`).
270
+ *
271
+ * **Atomic** — uses DDB `UpdateExpression: ADD UseCount :inc` rather than
272
+ * get-merge-put so concurrent stream invocations don't clobber each other's
273
+ * increments. (`editRef`'s get-then-put pattern would race here.)
274
+ *
275
+ * **Graceful degradation while Story C unshipped**: when the `contentLibrary`
276
+ * table doesn't exist yet (C / PC-1445 has not shipped), DDB raises
277
+ * `ResourceNotFoundException`. Helper catches it, logs INFO, and returns
278
+ * `false` — does NOT throw. This lets Story B ship independent of Story C
279
+ * (per PC-1444 AC-B.3 graceful-degradation clause).
280
+ *
281
+ * **Cross-account UseCount aggregation is out of scope** — Pluss Library uses
282
+ * across all clients are NOT counted here (§12.4 deferred).
283
+ *
284
+ * @param {string} templateId — the `Id` of the contentLibrary row.
285
+ * @param {string} contentLibraryTable — entity-agnostic; caller passes the
286
+ * table name (without prefix).
287
+ * @returns {Promise<boolean>} `true` if increment applied; `false` if table
288
+ * doesn't exist (graceful skip).
289
+ */
290
+ const incrementTemplateUseCount = async (templateId, contentLibraryTable) => {
291
+ if (!templateId || !contentLibraryTable) {
292
+ return false;
293
+ }
294
+ const params = {
295
+ TableName: `${process.env.tablePrefix}${contentLibraryTable}`,
296
+ Key: { Id: templateId },
297
+ UpdateExpression:
298
+ "ADD UseCount :inc SET LastUsedTimestamp = :now",
299
+ ExpressionAttributeValues: {
300
+ ":inc": 1,
301
+ ":now": moment.utc().unix(),
302
+ },
303
+ };
304
+ try {
305
+ await dynamoDb.update(params).promise();
306
+ return true;
307
+ } catch (error) {
308
+ if (error?.code === "ResourceNotFoundException") {
309
+ // Graceful degradation while Story C (contentLibrary table) is
310
+ // unshipped — see AC-B.3. Not an error; the rest of the stream
311
+ // handler should continue.
312
+ console.log(
313
+ "incrementTemplateUseCount: contentLibrary table not found — skipped (Story C not yet shipped)",
314
+ );
315
+ return false;
316
+ }
317
+ // Anything else is genuinely unexpected — surface it.
318
+ console.error(
319
+ "incrementTemplateUseCount: unexpected error",
320
+ error,
321
+ );
322
+ throw error;
323
+ }
324
+ };
325
+
43
326
  module.exports = {
327
+ // Story A
44
328
  isHqLocked,
329
+ // Story B
330
+ findCopiesByHqSourceId,
331
+ fanOutHqSource,
332
+ propagateHqEdit,
333
+ cascadeHqRetract,
334
+ incrementTemplateUseCount,
335
+ // Exposed for tests and other entities that want to extend the protected list
336
+ DEFAULT_PROTECTED_FIELDS,
45
337
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plusscommunities/pluss-core-aws",
3
- "version": "2.0.25-beta.0",
3
+ "version": "2.0.25-beta.1",
4
4
  "description": "Core extension package for Pluss Communities platform",
5
5
  "scripts": {
6
6
  "betapatch": "npm version prepatch --preid=beta",