@ampless/backend 1.0.0-alpha.59 → 1.0.0-alpha.61
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/dist/events/dispatcher.d.ts +26 -8
- package/dist/events/dispatcher.js +101 -19
- package/dist/index.d.ts +1 -0
- package/dist/index.js +34 -0
- package/package.json +3 -3
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import { DynamoDBStreamHandler } from 'aws-lambda';
|
|
2
2
|
|
|
3
|
+
interface CreateDispatcherHandlerOpts {
|
|
4
|
+
/**
|
|
5
|
+
* Days to retain each PostHistory revision before DynamoDB TTL deletes it.
|
|
6
|
+
* `0` (the default) means keep every revision forever — no `ttl` attribute
|
|
7
|
+
* is written. Supplied by the template shell from
|
|
8
|
+
* `cms.config.history?.retentionDays`.
|
|
9
|
+
*/
|
|
10
|
+
historyRetentionDays?: number;
|
|
11
|
+
}
|
|
3
12
|
/**
|
|
4
|
-
* DynamoDB-stream → SQS-fanout dispatcher. Wired to both the Post
|
|
5
|
-
* KvStore tables; routes content events to the trusted+untrusted
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* routing here.
|
|
13
|
+
* Build the DynamoDB-stream → SQS-fanout dispatcher. Wired to both the Post
|
|
14
|
+
* and KvStore tables; routes content events to the trusted+untrusted queues
|
|
15
|
+
* and site-settings updates likewise. trust_level isolation is enforced by
|
|
16
|
+
* the downstream Lambdas' IAM roles, not by message routing here.
|
|
9
17
|
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
18
|
+
* In addition, for each Post INSERT/MODIFY it writes one PostHistory revision
|
|
19
|
+
* snapshot (best-effort, never throws — see `writePostHistory`).
|
|
20
|
+
*
|
|
21
|
+
* The template's thin shell `amplify/events/dispatcher/handler.ts` calls this
|
|
22
|
+
* with `historyRetentionDays` from `cms.config`.
|
|
23
|
+
*/
|
|
24
|
+
declare function createDispatcherHandler(opts: CreateDispatcherHandlerOpts): DynamoDBStreamHandler;
|
|
25
|
+
/**
|
|
26
|
+
* Backward-compatible default handler. Existing template shells re-export
|
|
27
|
+
* `{ handler }` from this subpath and un-upgraded projects keep working with
|
|
28
|
+
* `historyRetentionDays: 0` (revisions kept forever, no ttl). New shells call
|
|
29
|
+
* `createDispatcherHandler({ historyRetentionDays })` directly.
|
|
12
30
|
*/
|
|
13
31
|
declare const handler: DynamoDBStreamHandler;
|
|
14
32
|
|
|
15
|
-
export { handler };
|
|
33
|
+
export { type CreateDispatcherHandlerOpts, createDispatcherHandler, handler };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/events/dispatcher.ts
|
|
2
2
|
import { unmarshall } from "@aws-sdk/util-dynamodb";
|
|
3
|
+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
4
|
+
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
|
|
3
5
|
import {
|
|
4
6
|
SQSClient,
|
|
5
7
|
SendMessageBatchCommand
|
|
@@ -15,6 +17,9 @@ function requireEnv(name) {
|
|
|
15
17
|
var sqs = new SQSClient({});
|
|
16
18
|
var TRUSTED_QUEUE_URL = requireEnv("TRUSTED_QUEUE_URL");
|
|
17
19
|
var UNTRUSTED_QUEUE_URL = requireEnv("UNTRUSTED_QUEUE_URL");
|
|
20
|
+
var ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}), {
|
|
21
|
+
marshallOptions: { removeUndefinedValues: true }
|
|
22
|
+
});
|
|
18
23
|
function tableNameFromArn(arn) {
|
|
19
24
|
if (!arn) return null;
|
|
20
25
|
const match = arn.match(/:table\/([^/]+)/);
|
|
@@ -84,27 +89,104 @@ async function sendBatch(queueUrl, entries) {
|
|
|
84
89
|
await sqs.send(new SendMessageBatchCommand({ QueueUrl: queueUrl, Entries: chunk }));
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
function revisedAtForRecord(record, post) {
|
|
93
|
+
if (post.updatedAt) return post.updatedAt;
|
|
94
|
+
const approx = record.dynamodb?.ApproximateCreationDateTime;
|
|
95
|
+
if (typeof approx === "number") {
|
|
96
|
+
return new Date(approx * 1e3).toISOString();
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
async function writePostHistory(record, tableName, historyRetentionDays) {
|
|
101
|
+
try {
|
|
102
|
+
if (record.eventName !== "INSERT" && record.eventName !== "MODIFY") return;
|
|
103
|
+
if (!record.dynamodb?.NewImage) return;
|
|
104
|
+
const post = unmarshall(record.dynamodb.NewImage);
|
|
105
|
+
if (!post.postId) {
|
|
106
|
+
console.error("[event-dispatcher] PostHistory: new image has no postId; skipping snapshot");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const revisedAt = revisedAtForRecord(record, post);
|
|
110
|
+
if (!revisedAt) {
|
|
111
|
+
console.error(
|
|
112
|
+
`[event-dispatcher] PostHistory: postId=${post.postId} has no updatedAt and no ApproximateCreationDateTime; skipping snapshot`
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const postHistoryId = `${post.postId}#${revisedAt}`;
|
|
117
|
+
const item = {
|
|
118
|
+
postHistoryId,
|
|
119
|
+
postId: post.postId,
|
|
120
|
+
revisedAt,
|
|
121
|
+
title: post.title,
|
|
122
|
+
slug: post.slug,
|
|
123
|
+
excerpt: post.excerpt,
|
|
124
|
+
format: post.format,
|
|
125
|
+
// body stays the AWSJSON string form, same as Post storage.
|
|
126
|
+
body: post.body,
|
|
127
|
+
status: post.status,
|
|
128
|
+
publishedAt: post.publishedAt,
|
|
129
|
+
tags: post.tags,
|
|
130
|
+
metadata: post.metadata,
|
|
131
|
+
createdAt: revisedAt,
|
|
132
|
+
updatedAt: revisedAt
|
|
133
|
+
};
|
|
134
|
+
if (historyRetentionDays > 0) {
|
|
135
|
+
item.ttl = Math.floor(new Date(revisedAt).getTime() / 1e3) + historyRetentionDays * 86400;
|
|
136
|
+
}
|
|
137
|
+
await ddb.send(
|
|
138
|
+
new PutCommand({
|
|
139
|
+
TableName: tableName,
|
|
140
|
+
Item: item,
|
|
141
|
+
ConditionExpression: "attribute_not_exists(postHistoryId)"
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err?.name === "ConditionalCheckFailedException") {
|
|
146
|
+
console.debug(
|
|
147
|
+
"[event-dispatcher] PostHistory: snapshot already exists (re-delivery), skipping"
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
96
150
|
}
|
|
151
|
+
console.error("[event-dispatcher] PostHistory: snapshot write failed", err);
|
|
97
152
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
153
|
+
}
|
|
154
|
+
function createDispatcherHandler(opts) {
|
|
155
|
+
const historyRetentionDays = opts.historyRetentionDays ?? 0;
|
|
156
|
+
return async (event) => {
|
|
157
|
+
const messages = [];
|
|
158
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
159
|
+
const historyTable = process.env.AMPLESS_POST_HISTORY_TABLE;
|
|
160
|
+
let warnedNoHistoryTable = false;
|
|
161
|
+
for (const record of event.Records) {
|
|
162
|
+
const tableName = tableNameFromArn(record.eventSourceARN);
|
|
163
|
+
if (tableName && tableName.startsWith("Post-")) {
|
|
164
|
+
messages.push(...emitContentEvents(record, timestamp));
|
|
165
|
+
if (historyTable) {
|
|
166
|
+
await writePostHistory(record, historyTable, historyRetentionDays);
|
|
167
|
+
} else if (!warnedNoHistoryTable) {
|
|
168
|
+
console.log(
|
|
169
|
+
"[event-dispatcher] AMPLESS_POST_HISTORY_TABLE not set; skipping PostHistory snapshots"
|
|
170
|
+
);
|
|
171
|
+
warnedNoHistoryTable = true;
|
|
172
|
+
}
|
|
173
|
+
} else if (tableName && tableName.startsWith("KvStore-")) {
|
|
174
|
+
messages.push(...emitKvEvents(record, timestamp));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (messages.length === 0) return;
|
|
178
|
+
const entries = messages.map((m, i) => ({
|
|
179
|
+
Id: `msg-${i}`,
|
|
180
|
+
MessageBody: JSON.stringify(m)
|
|
181
|
+
}));
|
|
182
|
+
await Promise.all([
|
|
183
|
+
sendBatch(TRUSTED_QUEUE_URL, entries),
|
|
184
|
+
sendBatch(UNTRUSTED_QUEUE_URL, entries)
|
|
185
|
+
]);
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
var handler = createDispatcherHandler({ historyRetentionDays: 0 });
|
|
108
189
|
export {
|
|
190
|
+
createDispatcherHandler,
|
|
109
191
|
handler
|
|
110
192
|
};
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -89,6 +89,9 @@ function defineAmplessBackend(opts) {
|
|
|
89
89
|
cfnKvTable.streamSpecification = { streamViewType: "NEW_AND_OLD_IMAGES" };
|
|
90
90
|
cfnKvTable.timeToLiveSpecification = { attributeName: "ttl", enabled: true };
|
|
91
91
|
const mcpTokenTable = backend.data.resources.tables["McpToken"];
|
|
92
|
+
const postHistoryTable = backend.data.resources.tables["PostHistory"];
|
|
93
|
+
const cfnPostHistoryTable = backend.data.resources.cfnResources.amplifyDynamoDbTables["PostHistory"];
|
|
94
|
+
cfnPostHistoryTable.timeToLiveSpecification = { attributeName: "ttl", enabled: true };
|
|
92
95
|
const eventsStack = backend.createStack("amplessEvents");
|
|
93
96
|
const eventsDlq = new Queue(eventsStack, "EventsDlq", {
|
|
94
97
|
retentionPeriod: Duration.days(14)
|
|
@@ -122,6 +125,8 @@ function defineAmplessBackend(opts) {
|
|
|
122
125
|
untrustedQueue.grantSendMessages(dispatcherFn);
|
|
123
126
|
dispatcherFn.addEnvironment("TRUSTED_QUEUE_URL", trustedQueue.queueUrl);
|
|
124
127
|
dispatcherFn.addEnvironment("UNTRUSTED_QUEUE_URL", untrustedQueue.queueUrl);
|
|
128
|
+
postHistoryTable.grantWriteData(dispatcherFn);
|
|
129
|
+
dispatcherFn.addEnvironment("AMPLESS_POST_HISTORY_TABLE", postHistoryTable.tableName);
|
|
125
130
|
const trustedFn = backend.processorTrusted.resources.lambda;
|
|
126
131
|
trustedFn.addEventSource(new SqsEventSource(trustedQueue, { batchSize: 5 }));
|
|
127
132
|
postTable.grantReadData(trustedFn);
|
|
@@ -519,6 +524,35 @@ function amplessSchemaModels(a, opts = {}) {
|
|
|
519
524
|
// admin side without redeploying.
|
|
520
525
|
allow.groups(["ampless-admin", "ampless-editor"])
|
|
521
526
|
]),
|
|
527
|
+
// Per-post revision history (Phase A). Each Post INSERT/MODIFY on the
|
|
528
|
+
// DDB stream produces one snapshot row, written by the event-dispatcher
|
|
529
|
+
// Lambda via the DDB SDK under its own IAM role — admin/editor read it
|
|
530
|
+
// back through AppSync but never write it (same pattern as PluginSecret
|
|
531
|
+
// / PluginSecretIndicator).
|
|
532
|
+
PostHistory: a.model({
|
|
533
|
+
// 冪等な決定的 ID: `${postId}#${revisedAt}`. Stream at-least-once 再配信で
|
|
534
|
+
// 同じ保存が二度届いても同一 ID で上書きされ重複しない。
|
|
535
|
+
postHistoryId: a.id().required(),
|
|
536
|
+
postId: a.id().required(),
|
|
537
|
+
// スナップショットした版の updatedAt(=保存時刻)。byPost のソートキー。
|
|
538
|
+
revisedAt: a.datetime().required(),
|
|
539
|
+
title: a.string(),
|
|
540
|
+
slug: a.string(),
|
|
541
|
+
excerpt: a.string(),
|
|
542
|
+
format: a.enum(["tiptap", "markdown", "html", "static"]),
|
|
543
|
+
body: a.json(),
|
|
544
|
+
status: a.enum(["draft", "published"]),
|
|
545
|
+
publishedAt: a.datetime(),
|
|
546
|
+
tags: a.string().array(),
|
|
547
|
+
metadata: a.json(),
|
|
548
|
+
// Unix epoch 秒。retentionDays>0 のときだけ書く(0/未設定=無期限)。
|
|
549
|
+
ttl: a.integer()
|
|
550
|
+
}).identifier(["postHistoryId"]).secondaryIndexes((index) => [
|
|
551
|
+
// .name() は DynamoDB 物理 index 名、.queryField() は client query method 名。
|
|
552
|
+
index("postId").sortKeys(["revisedAt"]).queryField("listByPost").name("byPost")
|
|
553
|
+
]).authorization((allow) => [
|
|
554
|
+
allow.groups(["ampless-admin", "ampless-editor"]).to(["read"])
|
|
555
|
+
]),
|
|
522
556
|
// Custom return type for public post reads. Decoupling from `Post` lets
|
|
523
557
|
// AppSync skip the model-level (admin-only) auth check on fields.
|
|
524
558
|
//
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ampless/backend",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.61",
|
|
4
4
|
"description": "Amplify Gen 2 backend factories for ampless: auth, data, storage, event processors, API key renewer",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -69,8 +69,8 @@
|
|
|
69
69
|
"@smithy/protocol-http": "^5.4.4",
|
|
70
70
|
"@smithy/signature-v4": "^5.4.4",
|
|
71
71
|
"fflate": "^0.8.3",
|
|
72
|
-
"@ampless/mcp-server": "1.0.0-alpha.
|
|
73
|
-
"ampless": "1.0.0-alpha.
|
|
72
|
+
"@ampless/mcp-server": "1.0.0-alpha.45",
|
|
73
|
+
"ampless": "1.0.0-alpha.39"
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
76
|
"@aws-amplify/backend": "^1",
|