@ampless/backend 1.0.0-alpha.58 → 1.0.0-alpha.60

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.
@@ -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 and
5
- * KvStore tables; routes content events to the trusted+untrusted
6
- * queues and site-settings updates likewise. trust_level isolation
7
- * is enforced by the downstream Lambdas' IAM roles, not by message
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
- * Re-exported by the template's thin shell
11
- * `amplify/events/dispatcher/handler.ts`.
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
- var handler = async (event) => {
88
- const messages = [];
89
- const timestamp = (/* @__PURE__ */ new Date()).toISOString();
90
- for (const record of event.Records) {
91
- const tableName = tableNameFromArn(record.eventSourceARN);
92
- if (tableName && tableName.startsWith("Post-")) {
93
- messages.push(...emitContentEvents(record, timestamp));
94
- } else if (tableName && tableName.startsWith("KvStore-")) {
95
- messages.push(...emitKvEvents(record, timestamp));
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
- if (messages.length === 0) return;
99
- const entries = messages.map((m, i) => ({
100
- Id: `msg-${i}`,
101
- MessageBody: JSON.stringify(m)
102
- }));
103
- await Promise.all([
104
- sendBatch(TRUSTED_QUEUE_URL, entries),
105
- sendBatch(UNTRUSTED_QUEUE_URL, entries)
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
@@ -230,6 +230,7 @@ declare function amplessSchemaModels(a: any, opts?: AmplessSchemaModelsOpts): {
230
230
  McpToken: any;
231
231
  PluginSecret: any;
232
232
  PluginSecretIndicator: any;
233
+ PostHistory: any;
233
234
  PublicPost: any;
234
235
  PublicPostConnection: any;
235
236
  PublicMedia: any;
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.58",
3
+ "version": "1.0.0-alpha.60",
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.42",
73
- "ampless": "1.0.0-alpha.36"
72
+ "@ampless/mcp-server": "1.0.0-alpha.44",
73
+ "ampless": "1.0.0-alpha.38"
74
74
  },
75
75
  "peerDependencies": {
76
76
  "@aws-amplify/backend": "^1",