@plusscommunities/pluss-core-aws 2.0.25-beta.0 → 2.0.25-beta.2
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/helper/hqPublishing.js +307 -3
- package/package.json +1 -1
package/helper/hqPublishing.js
CHANGED
|
@@ -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 ~
|
|
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,281 @@
|
|
|
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
|
+
// IMPORTANT: pass a fresh shallow copy per call. `editRef` synchronously
|
|
219
|
+
// mutates its `updates` argument (`updates[keyCol] = id` inside the Promise
|
|
220
|
+
// constructor) AFTER taking a `cloneDeep` snapshot. Across parallel
|
|
221
|
+
// `Promise.all(map(...))` invocations, iteration N's `cloneDeep` would
|
|
222
|
+
// capture iteration N-1's `RowId` mutation, then later overwrite copy N's
|
|
223
|
+
// `RowId` during the get-merge-put, writing copy N's content back to copy
|
|
224
|
+
// N-1's key (and leaving copy N's own row untouched). Per-call `{...propagated}`
|
|
225
|
+
// gives each invocation an isolated payload it can mutate safely.
|
|
226
|
+
await Promise.all(
|
|
227
|
+
copies.map((copy) =>
|
|
228
|
+
editRef(tableName, "RowId", copy.RowId, { ...propagated }),
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
return copies.length;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Cascade `Deleted: true` to every community copy when the HQ-source row is
|
|
236
|
+
* retracted (`Deleted: true` set on the HQ-source MODIFY). Per §12.2, data is
|
|
237
|
+
* preserved at the storage layer — only the `Deleted` flag flips; no rows are
|
|
238
|
+
* removed. Un-retract UI is out of scope this iteration.
|
|
239
|
+
*
|
|
240
|
+
* @param {object} hqSourceRow — NewImage of the retracted HQ-source row.
|
|
241
|
+
* @param {string} tableName — entity's table name.
|
|
242
|
+
* @param {object} [options]
|
|
243
|
+
* @param {string} [options.indexName="NewsletterEntriesHqSourceIdIndex"]
|
|
244
|
+
* @returns {Promise<number>} count of copies marked deleted.
|
|
245
|
+
*/
|
|
246
|
+
const cascadeHqRetract = async (hqSourceRow, tableName, options = {}) => {
|
|
247
|
+
const indexName =
|
|
248
|
+
options.indexName || "NewsletterEntriesHqSourceIdIndex";
|
|
249
|
+
|
|
250
|
+
if (!hqSourceRow || hqSourceRow.Site !== "hq" || !hqSourceRow.Deleted) {
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const copies = await findCopiesByHqSourceId(
|
|
255
|
+
hqSourceRow.Id,
|
|
256
|
+
tableName,
|
|
257
|
+
indexName,
|
|
258
|
+
);
|
|
259
|
+
if (copies.length === 0) {
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const deletionUpdate = {
|
|
264
|
+
Deleted: true,
|
|
265
|
+
Changed: moment.utc().unix(),
|
|
266
|
+
};
|
|
267
|
+
// Per-call shallow copy — see propagateHqEdit for the editRef-mutation-race
|
|
268
|
+
// rationale. Even though `deletionUpdate` is tiny, the same RowId clobber
|
|
269
|
+
// happens here: under sharing, only the FIRST copy actually receives
|
|
270
|
+
// `Deleted: true`; subsequent writes land at the wrong RowId.
|
|
271
|
+
await Promise.all(
|
|
272
|
+
copies.map((copy) =>
|
|
273
|
+
editRef(tableName, "RowId", copy.RowId, { ...deletionUpdate }),
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
return copies.length;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Atomically increment `UseCount` on a `contentLibrary` template row when a
|
|
281
|
+
* new entity row is created from that template (`TemplateId !== null`).
|
|
282
|
+
*
|
|
283
|
+
* **Atomic** — uses DDB `UpdateExpression: ADD UseCount :inc` rather than
|
|
284
|
+
* get-merge-put so concurrent stream invocations don't clobber each other's
|
|
285
|
+
* increments. (`editRef`'s get-then-put pattern would race here.)
|
|
286
|
+
*
|
|
287
|
+
* **Graceful degradation while Story C unshipped**: when the `contentLibrary`
|
|
288
|
+
* table doesn't exist yet (C / PC-1445 has not shipped), DDB raises
|
|
289
|
+
* `ResourceNotFoundException`. Helper catches it, logs INFO, and returns
|
|
290
|
+
* `false` — does NOT throw. This lets Story B ship independent of Story C
|
|
291
|
+
* (per PC-1444 AC-B.3 graceful-degradation clause).
|
|
292
|
+
*
|
|
293
|
+
* **Cross-account UseCount aggregation is out of scope** — Pluss Library uses
|
|
294
|
+
* across all clients are NOT counted here (§12.4 deferred).
|
|
295
|
+
*
|
|
296
|
+
* @param {string} templateId — the `Id` of the contentLibrary row.
|
|
297
|
+
* @param {string} contentLibraryTable — entity-agnostic; caller passes the
|
|
298
|
+
* table name (without prefix).
|
|
299
|
+
* @returns {Promise<boolean>} `true` if increment applied; `false` if table
|
|
300
|
+
* doesn't exist (graceful skip).
|
|
301
|
+
*/
|
|
302
|
+
const incrementTemplateUseCount = async (templateId, contentLibraryTable) => {
|
|
303
|
+
if (!templateId || !contentLibraryTable) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const params = {
|
|
307
|
+
TableName: `${process.env.tablePrefix}${contentLibraryTable}`,
|
|
308
|
+
Key: { Id: templateId },
|
|
309
|
+
UpdateExpression:
|
|
310
|
+
"ADD UseCount :inc SET LastUsedTimestamp = :now",
|
|
311
|
+
ExpressionAttributeValues: {
|
|
312
|
+
":inc": 1,
|
|
313
|
+
":now": moment.utc().unix(),
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
try {
|
|
317
|
+
await dynamoDb.update(params).promise();
|
|
318
|
+
return true;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (error?.code === "ResourceNotFoundException") {
|
|
321
|
+
// Graceful degradation while Story C (contentLibrary table) is
|
|
322
|
+
// unshipped — see AC-B.3. Not an error; the rest of the stream
|
|
323
|
+
// handler should continue.
|
|
324
|
+
console.log(
|
|
325
|
+
"incrementTemplateUseCount: contentLibrary table not found — skipped (Story C not yet shipped)",
|
|
326
|
+
);
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
// Anything else is genuinely unexpected — surface it.
|
|
330
|
+
console.error(
|
|
331
|
+
"incrementTemplateUseCount: unexpected error",
|
|
332
|
+
error,
|
|
333
|
+
);
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
43
338
|
module.exports = {
|
|
339
|
+
// Story A
|
|
44
340
|
isHqLocked,
|
|
341
|
+
// Story B
|
|
342
|
+
findCopiesByHqSourceId,
|
|
343
|
+
fanOutHqSource,
|
|
344
|
+
propagateHqEdit,
|
|
345
|
+
cascadeHqRetract,
|
|
346
|
+
incrementTemplateUseCount,
|
|
347
|
+
// Exposed for tests and other entities that want to extend the protected list
|
|
348
|
+
DEFAULT_PROTECTED_FIELDS,
|
|
45
349
|
};
|
package/package.json
CHANGED