@ampless/backend 1.0.0-alpha.26 → 1.0.0-alpha.28

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.
@@ -6,7 +6,9 @@ import {
6
6
  SQSClient,
7
7
  SendMessageBatchCommand
8
8
  } from "@aws-sdk/client-sqs";
9
- import { detectContentEvents } from "ampless";
9
+ import {
10
+ detectContentEvents
11
+ } from "ampless";
10
12
  function requireEnv(name) {
11
13
  const v = process.env[name];
12
14
  if (!v) throw new Error(`event-dispatcher: missing required env var ${name}`);
@@ -20,15 +22,37 @@ function tableNameFromArn(arn) {
20
22
  const match = arn.match(/:table\/([^/]+)/);
21
23
  return match ? match[1] : null;
22
24
  }
25
+ function projectPost(raw) {
26
+ if (!raw || !raw.postId || !raw.slug || !raw.title) return null;
27
+ return {
28
+ postId: raw.postId,
29
+ slug: raw.slug,
30
+ title: raw.title,
31
+ status: raw.status ?? "draft",
32
+ publishedAt: raw.publishedAt,
33
+ tags: raw.tags
34
+ };
35
+ }
23
36
  function emitContentEvents(record, timestamp) {
24
37
  const oldItem = record.dynamodb?.OldImage ? unmarshall(record.dynamodb.OldImage) : null;
25
38
  const newItem = record.dynamodb?.NewImage ? unmarshall(record.dynamodb.NewImage) : null;
39
+ const events = [];
40
+ const previous = oldItem ? projectPost(oldItem) : null;
41
+ const next = newItem ? projectPost(newItem) : null;
42
+ if (previous || next) {
43
+ const indexPayload = { previous, next };
44
+ events.push({
45
+ type: "post.index.refresh",
46
+ payload: indexPayload,
47
+ timestamp
48
+ });
49
+ }
26
50
  const types = detectContentEvents({
27
51
  eventName: record.eventName,
28
52
  oldStatus: oldItem?.status,
29
53
  newStatus: newItem?.status
30
54
  });
31
- if (types.length === 0) return [];
55
+ if (types.length === 0) return events;
32
56
  const item = newItem ?? oldItem ?? {};
33
57
  const payload = {
34
58
  postId: item.postId,
@@ -38,7 +62,10 @@ function emitContentEvents(record, timestamp) {
38
62
  publishedAt: item.publishedAt,
39
63
  tags: item.tags
40
64
  };
41
- return types.map((type) => ({ type, payload, timestamp }));
65
+ for (const type of types) {
66
+ events.push({ type, payload, timestamp });
67
+ }
68
+ return events;
42
69
  }
43
70
  function emitKvEvents(record, timestamp) {
44
71
  const item = record.dynamodb?.NewImage ?? record.dynamodb?.OldImage ? unmarshall(record.dynamodb.NewImage ?? record.dynamodb.OldImage) : {};
@@ -3,10 +3,44 @@ import "../chunk-BYXBJQAS.js";
3
3
  // src/events/processor-trusted.ts
4
4
  import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
5
5
  import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
6
- import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
6
+ import {
7
+ DynamoDBDocumentClient,
8
+ DeleteCommand,
9
+ PutCommand,
10
+ QueryCommand
11
+ } from "@aws-sdk/lib-dynamodb";
7
12
  import {
8
13
  formatPublicAssetUrl
9
14
  } from "ampless";
15
+
16
+ // src/events/posttag-sync.ts
17
+ function computePostTagDiff(payload) {
18
+ const previousEntries = postTagItemsFromPost(payload.previous);
19
+ const nextEntries = postTagItemsFromPost(payload.next);
20
+ const nextKeys = new Set(nextEntries.map(itemKey));
21
+ const deletes = previousEntries.filter((p) => !nextKeys.has(itemKey(p))).map((p) => ({ tag: p.tag, publishedAtPostId: p.publishedAtPostId }));
22
+ return { deletes, puts: nextEntries };
23
+ }
24
+ function postTagItemsFromPost(p) {
25
+ if (!p) return [];
26
+ if (p.status !== "published") return [];
27
+ if (!p.publishedAt) return [];
28
+ const tags = Array.isArray(p.tags) ? p.tags : [];
29
+ return tags.filter((t) => typeof t === "string" && t.length > 0).map((tag) => ({
30
+ tag,
31
+ publishedAtPostId: `${p.publishedAt}#${p.postId}`,
32
+ postId: p.postId,
33
+ publishedAt: p.publishedAt,
34
+ slug: p.slug,
35
+ title: p.title,
36
+ tags
37
+ }));
38
+ }
39
+ function itemKey(item) {
40
+ return `${item.tag}|${item.publishedAtPostId}`;
41
+ }
42
+
43
+ // src/events/processor-trusted.ts
10
44
  function requireEnv(name) {
11
45
  const v = process.env[name];
12
46
  if (!v) throw new Error(`processor-trusted: missing required env var ${name}`);
@@ -30,6 +64,7 @@ function createProcessorTrustedHandler(opts) {
30
64
  const BUCKET = requireEnv("AMPLESS_BUCKET_NAME");
31
65
  const POST_TABLE = requireEnv("AMPLESS_POST_TABLE");
32
66
  const KV_TABLE = requireEnv("AMPLESS_KV_TABLE");
67
+ const POSTTAG_TABLE = requireEnv("AMPLESS_POSTTAG_TABLE");
33
68
  const REGION = requireEnv("AWS_REGION");
34
69
  async function listPublished() {
35
70
  const items = [];
@@ -82,6 +117,31 @@ function createProcessorTrustedHandler(opts) {
82
117
  }
83
118
  };
84
119
  }
120
+ async function rebuildPostTagsForPost(payload) {
121
+ const { deletes, puts } = computePostTagDiff(payload);
122
+ await Promise.all([
123
+ ...deletes.map(
124
+ (key) => ddb.send(
125
+ new DeleteCommand({
126
+ TableName: POSTTAG_TABLE,
127
+ Key: key
128
+ })
129
+ )
130
+ ),
131
+ ...puts.map(
132
+ (item) => ddb.send(
133
+ new PutCommand({
134
+ TableName: POSTTAG_TABLE,
135
+ Item: item
136
+ })
137
+ )
138
+ )
139
+ ]);
140
+ const postId = payload.next?.postId ?? payload.previous?.postId ?? "(unknown)";
141
+ console.log(
142
+ `[posttag-sync] postId=${postId} removed=${deletes.length} upserted=${puts.length}`
143
+ );
144
+ }
85
145
  async function rebuildSiteSettingsCache() {
86
146
  const settings = {};
87
147
  let exclusiveStartKey;
@@ -136,6 +196,14 @@ function createProcessorTrustedHandler(opts) {
136
196
  throw err;
137
197
  }
138
198
  }
199
+ if (parsed.type === "post.index.refresh") {
200
+ try {
201
+ await rebuildPostTagsForPost(parsed.payload);
202
+ } catch (err) {
203
+ console.error("[trusted-processor] posttag-sync failed", err);
204
+ throw err;
205
+ }
206
+ }
139
207
  for (const plugin of trustedPlugins) {
140
208
  const hook = plugin.hooks?.[parsed.type];
141
209
  if (!hook) continue;
@@ -5,7 +5,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
5
5
  import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
6
6
  import { createHash } from "crypto";
7
7
 
8
- // ../mcp-server/dist/chunk-HVSLKTKC.js
8
+ // ../mcp-server/dist/chunk-WAP4WAMQ.js
9
9
  import {
10
10
  decodeAwsJson
11
11
  } from "ampless";
@@ -159,92 +159,6 @@ async function getPost(client, args) {
159
159
  const item = data.listPosts.items[0];
160
160
  return item ? toCorePost(item) : null;
161
161
  }
162
- function entries(post) {
163
- if (post.status !== "published" || !post.publishedAt || !post.tags?.length) return [];
164
- return post.tags.map((tag) => ({
165
- tag,
166
- publishedAtPostId: `${post.publishedAt}#${post.postId}`
167
- }));
168
- }
169
- function entryKey(e) {
170
- return `${e.tag}|${e.publishedAtPostId}`;
171
- }
172
- var CREATE_POST_TAG = (
173
- /* GraphQL */
174
- `
175
- mutation CreatePostTag($input: CreatePostTagInput!) {
176
- createPostTag(input: $input) {
177
- tag
178
- publishedAtPostId
179
- }
180
- }
181
- `
182
- );
183
- var UPDATE_POST_TAG = (
184
- /* GraphQL */
185
- `
186
- mutation UpdatePostTag($input: UpdatePostTagInput!) {
187
- updatePostTag(input: $input) {
188
- tag
189
- publishedAtPostId
190
- }
191
- }
192
- `
193
- );
194
- var DELETE_POST_TAG = (
195
- /* GraphQL */
196
- `
197
- mutation DeletePostTag($input: DeletePostTagInput!) {
198
- deletePostTag(input: $input) {
199
- tag
200
- publishedAtPostId
201
- }
202
- }
203
- `
204
- );
205
- async function syncPostTags(client, post, oldPost) {
206
- const oldEntries = oldPost ? entries(oldPost) : [];
207
- const newEntries = entries(post);
208
- const oldKeys = new Set(oldEntries.map(entryKey));
209
- const newKeys = new Set(newEntries.map(entryKey));
210
- await Promise.all(
211
- oldEntries.filter((e) => !newKeys.has(entryKey(e))).map(
212
- (e) => client.query(DELETE_POST_TAG, {
213
- input: { tag: e.tag, publishedAtPostId: e.publishedAtPostId }
214
- })
215
- )
216
- );
217
- await Promise.all(
218
- newEntries.filter((e) => !oldKeys.has(entryKey(e))).map(
219
- (e) => client.query(CREATE_POST_TAG, {
220
- input: {
221
- tag: e.tag,
222
- publishedAtPostId: e.publishedAtPostId,
223
- postId: post.postId,
224
- publishedAt: post.publishedAt,
225
- slug: post.slug,
226
- title: post.title,
227
- excerpt: post.excerpt,
228
- tags: post.tags ?? []
229
- }
230
- })
231
- )
232
- );
233
- await Promise.all(
234
- newEntries.filter((e) => oldKeys.has(entryKey(e))).map(
235
- (e) => client.query(UPDATE_POST_TAG, {
236
- input: {
237
- tag: e.tag,
238
- publishedAtPostId: e.publishedAtPostId,
239
- slug: post.slug,
240
- title: post.title,
241
- excerpt: post.excerpt,
242
- tags: post.tags ?? []
243
- }
244
- })
245
- )
246
- );
247
- }
248
162
  var MUTATION = (
249
163
  /* GraphQL */
250
164
  `
@@ -312,9 +226,7 @@ async function createPost(client, args) {
312
226
  metadata: args.metadata !== void 0 ? encodeAwsJson(args.metadata) : void 0
313
227
  }
314
228
  });
315
- const created = toCorePost(data.createPost);
316
- await syncPostTags(client, created, null);
317
- return created;
229
+ return toCorePost(data.createPost);
318
230
  }
319
231
  var MUTATION2 = (
320
232
  /* GraphQL */
@@ -364,7 +276,6 @@ var updatePostSchema = {
364
276
  }
365
277
  };
366
278
  async function updatePost(client, args) {
367
- const oldPost = await getPost(client, { postId: args.postId });
368
279
  const input = { postId: args.postId };
369
280
  if (args.slug !== void 0) input.slug = args.slug;
370
281
  if (args.title !== void 0) input.title = args.title;
@@ -376,9 +287,7 @@ async function updatePost(client, args) {
376
287
  if (args.tags !== void 0) input.tags = args.tags;
377
288
  if (args.metadata !== void 0) input.metadata = encodeAwsJson2(args.metadata);
378
289
  const data = await client.query(MUTATION2, { input });
379
- const updated = toCorePost(data.updatePost);
380
- await syncPostTags(client, updated, oldPost);
381
- return updated;
290
+ return toCorePost(data.updatePost);
382
291
  }
383
292
  var MUTATION3 = (
384
293
  /* GraphQL */
@@ -398,10 +307,6 @@ var deletePostSchema = {
398
307
  }
399
308
  };
400
309
  async function deletePost(client, args) {
401
- const oldPost = await getPost(client, { postId: args.postId });
402
- if (oldPost) {
403
- await syncPostTags(client, { ...oldPost, status: "draft" }, oldPost);
404
- }
405
310
  const data = await client.query(MUTATION3, { input: { postId: args.postId } });
406
311
  return { deleted: data.deletePost };
407
312
  }
@@ -526,11 +431,11 @@ function getSchema() {
526
431
  var DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
527
432
  function extractZipFromBuffer(buffer, opts = {}) {
528
433
  const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
529
- const entries2 = unzipSync(buffer);
434
+ const entries = unzipSync(buffer);
530
435
  const files = [];
531
436
  const issues = [];
532
437
  let totalBytes = 0;
533
- for (const [name, data] of Object.entries(entries2)) {
438
+ for (const [name, data] of Object.entries(entries)) {
534
439
  if (name.endsWith("/")) continue;
535
440
  const reason = validateBundlePath(name);
536
441
  if (reason) {
@@ -588,7 +493,6 @@ async function upsertStaticPost(graphql, slug, body, fields) {
588
493
  }
589
494
  const data2 = await graphql.query(UPDATE_MUTATION, { input: input2 });
590
495
  const updated = toCorePost(data2.updatePost);
591
- await syncPostTags(graphql, updated, existing);
592
496
  return { post: updated, created: false };
593
497
  }
594
498
  if (!fields.title) {
@@ -613,7 +517,6 @@ async function upsertStaticPost(graphql, slug, body, fields) {
613
517
  if (fields.metadata !== void 0) input.metadata = encodeAwsJson3(fields.metadata);
614
518
  const data = await graphql.query(CREATE_MUTATION, { input });
615
519
  const created = toCorePost(data.createPost);
616
- await syncPostTags(graphql, created, null);
617
520
  return { post: created, created: true };
618
521
  }
619
522
  var uploadStaticBundleSchema = {
package/dist/index.js CHANGED
@@ -133,25 +133,30 @@ function defineAmplessBackend(opts) {
133
133
  })
134
134
  );
135
135
  kvTable.grantReadData(trustedFn);
136
+ const postTagTable = backend.data.resources.tables["PostTag"];
137
+ postTagTable.grantWriteData(trustedFn);
136
138
  trustedFn.addToRolePolicy(
137
139
  new PolicyStatement({
138
140
  effect: Effect.ALLOW,
139
141
  actions: ["s3:PutObject", "s3:DeleteObject"],
140
142
  resources: [
141
143
  `${backend.storage.resources.bucket.bucketArn}/public/plugins/*`,
142
- // Built-in cache: rebuildSiteSettingsCache writes the per-site
143
- // JSON the public site reads. Without this entry the
144
- // `site.settings.updated` handler S3 PutObject call fails
145
- // silently (CloudWatch shows AccessDenied; the public site never
146
- // sees theme.active overrides because the cache file never
147
- // appears).
148
- `${backend.storage.resources.bucket.bucketArn}/public/site-settings/*`
144
+ // Built-in cache: rebuildSiteSettingsCache writes the single
145
+ // JSON file the public site reads. Exact-match resource
146
+ // historically this was `public/site-settings/<siteId>.json`
147
+ // (multi-site era) and the wildcard pattern that worked then
148
+ // (`public/site-settings/*`) does NOT match the current
149
+ // single-file key `public/site-settings.json`. Wrong pattern
150
+ // fails the PutObject silently with AccessDenied, so the
151
+ // public site never sees admin-side theme / settings changes.
152
+ `${backend.storage.resources.bucket.bucketArn}/public/site-settings.json`
149
153
  ]
150
154
  })
151
155
  );
152
156
  trustedFn.addEnvironment("AMPLESS_BUCKET_NAME", backend.storage.resources.bucket.bucketName);
153
157
  trustedFn.addEnvironment("AMPLESS_POST_TABLE", postTable.tableName);
154
158
  trustedFn.addEnvironment("AMPLESS_KV_TABLE", kvTable.tableName);
159
+ trustedFn.addEnvironment("AMPLESS_POSTTAG_TABLE", postTagTable.tableName);
155
160
  const untrustedFn = backend.processorUntrusted.resources.lambda;
156
161
  untrustedFn.addEventSource(new SqsEventSource(untrustedQueue, { batchSize: 5 }));
157
162
  const graphqlApi = backend.data.resources.cfnResources.cfnGraphqlApi;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/backend",
3
- "version": "1.0.0-alpha.26",
3
+ "version": "1.0.0-alpha.28",
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",
@@ -65,8 +65,8 @@
65
65
  "@smithy/protocol-http": "^5.4.4",
66
66
  "@smithy/signature-v4": "^5.4.4",
67
67
  "fflate": "^0.8.3",
68
- "ampless": "1.0.0-alpha.14",
69
- "@ampless/mcp-server": "1.0.0-alpha.16"
68
+ "ampless": "1.0.0-alpha.15",
69
+ "@ampless/mcp-server": "1.0.0-alpha.17"
70
70
  },
71
71
  "peerDependencies": {
72
72
  "@aws-amplify/backend": "^1",