@ampless/backend 1.0.0-alpha.36 → 1.0.0-alpha.37

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.
@@ -368,6 +368,161 @@ async function uploadMedia(graphql, storage, args) {
368
368
  });
369
369
  return { media: data.createMedia, url: putResult.url };
370
370
  }
371
+ var MEDIA_FIELDS = (
372
+ /* GraphQL */
373
+ `
374
+ fragment MediaFields on Media {
375
+ mediaId
376
+ src
377
+ mimeType
378
+ size
379
+ createdAt
380
+ updatedAt
381
+ }
382
+ `
383
+ );
384
+ function toMediaResult(row, storage) {
385
+ return {
386
+ mediaId: row.mediaId,
387
+ src: row.src,
388
+ url: storage.publicUrl(row.src),
389
+ mimeType: row.mimeType,
390
+ size: row.size,
391
+ createdAt: row.createdAt,
392
+ updatedAt: row.updatedAt
393
+ };
394
+ }
395
+ var QUERY2 = (
396
+ /* GraphQL */
397
+ `
398
+ ${MEDIA_FIELDS}
399
+ query ListMedia($filter: ModelMediaFilterInput, $limit: Int, $nextToken: String) {
400
+ listMedia(filter: $filter, limit: $limit, nextToken: $nextToken) {
401
+ items {
402
+ ...MediaFields
403
+ }
404
+ nextToken
405
+ }
406
+ }
407
+ `
408
+ );
409
+ var listMediaSchema = {
410
+ type: "object",
411
+ properties: {
412
+ limit: {
413
+ type: "integer",
414
+ minimum: 1,
415
+ maximum: 100,
416
+ description: "Max results (default 20)"
417
+ },
418
+ nextToken: { type: "string", description: "Pagination cursor from a previous call" },
419
+ mimeType: {
420
+ type: "string",
421
+ description: 'Filter by MIME type prefix (beginsWith): "image/" matches all images, "image/png" matches PNG only.'
422
+ },
423
+ prefix: {
424
+ type: "string",
425
+ description: 'Filter by S3 key prefix (beginsWith) on `src`, e.g. "public/media/2024/" to find files from a given year.'
426
+ },
427
+ createdAfter: {
428
+ type: "string",
429
+ description: "ISO 8601 timestamp; only return media created at or after this."
430
+ },
431
+ createdBefore: {
432
+ type: "string",
433
+ description: "ISO 8601 timestamp; only return media created at or before this."
434
+ }
435
+ }
436
+ };
437
+ function buildMediaFilter(args) {
438
+ const conditions = [];
439
+ if (args.mimeType) conditions.push({ mimeType: { beginsWith: args.mimeType } });
440
+ if (args.prefix) conditions.push({ src: { beginsWith: args.prefix } });
441
+ if (args.createdAfter && args.createdBefore) {
442
+ conditions.push({ createdAt: { between: [args.createdAfter, args.createdBefore] } });
443
+ } else if (args.createdAfter) {
444
+ conditions.push({ createdAt: { ge: args.createdAfter } });
445
+ } else if (args.createdBefore) {
446
+ conditions.push({ createdAt: { le: args.createdBefore } });
447
+ }
448
+ if (conditions.length === 0) return void 0;
449
+ if (conditions.length === 1) return conditions[0];
450
+ return { and: conditions };
451
+ }
452
+ async function listMedia(graphql, storage, args = {}) {
453
+ const filter = buildMediaFilter(args);
454
+ const data = await graphql.query(QUERY2, {
455
+ filter,
456
+ limit: args.limit ?? 20,
457
+ nextToken: args.nextToken
458
+ });
459
+ return {
460
+ media: data.listMedia.items.map((row) => toMediaResult(row, storage)),
461
+ nextToken: data.listMedia.nextToken
462
+ };
463
+ }
464
+ var QUERY3 = (
465
+ /* GraphQL */
466
+ `
467
+ ${MEDIA_FIELDS}
468
+ query SearchMedia($filter: ModelMediaFilterInput, $limit: Int, $nextToken: String) {
469
+ listMedia(filter: $filter, limit: $limit, nextToken: $nextToken) {
470
+ items {
471
+ ...MediaFields
472
+ }
473
+ nextToken
474
+ }
475
+ }
476
+ `
477
+ );
478
+ var MAX_PAGES = 20;
479
+ var searchMediaSchema = {
480
+ type: "object",
481
+ required: ["query"],
482
+ properties: {
483
+ query: {
484
+ type: "string",
485
+ description: 'Substring matched (case-sensitive `contains`) against `src` (which includes the filename) and `mimeType`. e.g. "logo", ".png", "image/png".'
486
+ },
487
+ limit: {
488
+ type: "integer",
489
+ minimum: 1,
490
+ maximum: 100,
491
+ description: "Target number of matches to collect before stopping (default 20). A soft target \u2014 the last page may push the result slightly past it."
492
+ },
493
+ nextToken: { type: "string", description: "Pagination cursor from a previous call" }
494
+ }
495
+ };
496
+ async function searchMedia(graphql, storage, args) {
497
+ if (!args.query) {
498
+ throw new Error("search_media: `query` is required");
499
+ }
500
+ const limit = args.limit ?? 20;
501
+ const filter = {
502
+ or: [{ src: { contains: args.query } }, { mimeType: { contains: args.query } }]
503
+ };
504
+ const media = [];
505
+ let token = args.nextToken ?? null;
506
+ let pages = 0;
507
+ do {
508
+ const data = await graphql.query(QUERY3, {
509
+ filter,
510
+ limit,
511
+ nextToken: token ?? void 0
512
+ });
513
+ pages += 1;
514
+ for (const row of data.listMedia.items) {
515
+ media.push(toMediaResult(row, storage));
516
+ }
517
+ token = data.listMedia.nextToken;
518
+ } while (media.length < limit && token && pages < MAX_PAGES);
519
+ const truncated = media.length < limit && token !== null && pages >= MAX_PAGES;
520
+ return {
521
+ media,
522
+ nextToken: token,
523
+ ...truncated ? { truncated: true } : {}
524
+ };
525
+ }
371
526
  var GET_BY_ID2 = (
372
527
  /* GraphQL */
373
528
  `
@@ -410,6 +565,10 @@ var deleteMediaSchema = {
410
565
  src: {
411
566
  type: "string",
412
567
  description: "Full S3 key including `public/` (e.g. 'public/media/2026/05/1714400000000-photo.jpg'). Use when you only have the public URL \u2014 strip `/api/media/` and prepend `public/`. Provide either `mediaId` or `src`."
568
+ },
569
+ dryRun: {
570
+ type: "boolean",
571
+ description: "When true, resolve the target but delete nothing (no S3 delete, no row delete). Returns `{ deleted: false, dryRun: true, ... }` previewing what would be removed. Use it to confirm before the real delete."
413
572
  }
414
573
  }
415
574
  };
@@ -417,6 +576,7 @@ async function deleteMedia(graphql, storage, args) {
417
576
  if (!args.mediaId && !args.src) {
418
577
  throw new Error("delete_media: provide `mediaId` or `src`");
419
578
  }
579
+ const dryRun = args.dryRun === true;
420
580
  let mediaId;
421
581
  let src;
422
582
  if (args.mediaId) {
@@ -434,6 +594,14 @@ async function deleteMedia(graphql, storage, args) {
434
594
  }
435
595
  if (!mediaId) {
436
596
  if (args.src) {
597
+ if (dryRun) {
598
+ return {
599
+ deleted: false,
600
+ dryRun: true,
601
+ reason: "dry run \u2014 media row not found; would attempt S3 object delete",
602
+ src: args.src
603
+ };
604
+ }
437
605
  await storage.deleteObject(args.src);
438
606
  return {
439
607
  deleted: false,
@@ -443,7 +611,17 @@ async function deleteMedia(graphql, storage, args) {
443
611
  }
444
612
  return {
445
613
  deleted: false,
446
- reason: "media row not found for mediaId"
614
+ ...dryRun ? { dryRun: true } : {},
615
+ reason: dryRun ? "dry run \u2014 media row not found for mediaId; nothing to delete" : "media row not found for mediaId"
616
+ };
617
+ }
618
+ if (dryRun) {
619
+ return {
620
+ deleted: false,
621
+ dryRun: true,
622
+ reason: "dry run \u2014 nothing deleted",
623
+ mediaId,
624
+ src
447
625
  };
448
626
  }
449
627
  await storage.deleteObject(src);
@@ -1359,9 +1537,21 @@ var tools = [
1359
1537
  inputSchema: uploadMediaSchema,
1360
1538
  handler: (args, ctx) => uploadMedia(ctx.graphql, ctx.storage(), args)
1361
1539
  },
1540
+ {
1541
+ name: "list_media",
1542
+ description: 'List media files in the CMS. Optional filters: `mimeType` (prefix match \u2014 "image/" matches all images, "image/png" only PNG), `prefix` (S3 key prefix on `src`, e.g. "public/media/2024/"), `createdAfter` / `createdBefore` (ISO 8601 date range). Returns up to `limit` rows (default 20) \u2014 each `{ mediaId, src, url, mimeType, size, createdAt, updatedAt }` \u2014 plus a `nextToken` cursor. Note: filters apply after the page read, so a page may return fewer than `limit` rows while a `nextToken` remains \u2014 follow the cursor. Use this to find media to delete without remembering `upload_media` responses.',
1543
+ inputSchema: listMediaSchema,
1544
+ handler: (args, ctx) => listMedia(ctx.graphql, ctx.storage(), args)
1545
+ },
1546
+ {
1547
+ name: "search_media",
1548
+ description: 'Search media by substring (case-sensitive `contains`) across `src` (which includes the filename) and `mimeType`. Pass `query` (e.g. "logo", ".png", "image/png"). Walks DynamoDB pages internally until it collects at least `limit` matches (default 20), exhausts the table, or hits its page cap \u2014 so `limit` is a soft target and the result may run slightly past it. Returns the same row shape as `list_media` plus `nextToken`; `truncated: true` means the page cap was hit with more to scan (pass `nextToken` back to continue).',
1549
+ inputSchema: searchMediaSchema,
1550
+ handler: (args, ctx) => searchMedia(ctx.graphql, ctx.storage(), args)
1551
+ },
1362
1552
  {
1363
1553
  name: "delete_media",
1364
- description: "Delete a media file: removes the S3 object and the Media row. Pass `mediaId` (from `upload_media`'s response) or `src` (full S3 key like `public/media/2026/05/...`). When only `src` is given, looks up the Media row via `getMediaBySrc`. S3 delete runs first, then the DDB row delete \u2014 both are idempotent so re-running converges on missing-key cases. Returns `{ deleted: false }` instead of throwing when no Media row matches; if `src` was supplied directly the S3 object is still removed (use this to sweep orphan files).",
1554
+ description: "Delete a media file: removes the S3 object and the Media row. Pass `mediaId` (from `upload_media`'s response, or `list_media` / `search_media`) or `src` (full S3 key like `public/media/2026/05/...`). When only `src` is given, looks up the Media row via `getMediaBySrc`. S3 delete runs first, then the DDB row delete \u2014 both are idempotent so re-running converges on missing-key cases. Pass `dryRun: true` to preview what would be deleted without touching anything (returns `{ deleted: false, dryRun: true, ... }`). Returns `{ deleted: false }` instead of throwing when no Media row matches; if `src` was supplied directly the S3 object is still removed (use this to sweep orphan files).",
1365
1555
  inputSchema: deleteMediaSchema,
1366
1556
  handler: (args, ctx) => deleteMedia(
1367
1557
  ctx.graphql,
@@ -1532,6 +1722,9 @@ function createMcpStorageClient(opts) {
1532
1722
  token = res.IsTruncated ? res.NextContinuationToken : void 0;
1533
1723
  } while (token);
1534
1724
  return out;
1725
+ },
1726
+ publicUrl(key) {
1727
+ return formatPublicAssetUrl(opts.bucket, opts.region, key);
1535
1728
  }
1536
1729
  };
1537
1730
  }
package/dist/index.js CHANGED
@@ -188,7 +188,7 @@ function defineAmplessBackend(opts) {
188
188
  mcpHandlerFn.addToRolePolicy(
189
189
  new PolicyStatement({
190
190
  effect: Effect.ALLOW,
191
- actions: ["s3:PutObject"],
191
+ actions: ["s3:PutObject", "s3:DeleteObject"],
192
192
  resources: [`${backend.storage.resources.bucket.bucketArn}/public/media/*`]
193
193
  })
194
194
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/backend",
3
- "version": "1.0.0-alpha.36",
3
+ "version": "1.0.0-alpha.37",
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.20",
69
- "@ampless/mcp-server": "1.0.0-alpha.25"
68
+ "@ampless/mcp-server": "1.0.0-alpha.26",
69
+ "ampless": "1.0.0-alpha.20"
70
70
  },
71
71
  "peerDependencies": {
72
72
  "@aws-amplify/backend": "^1",