@reventlessdev/reventless-aws 3.0.0-alpha.239 → 3.0.0-alpha.241

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/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.241 (2026-07-29)
7
+
8
+ ### Features
9
+
10
+ * **platform:** serve and publish declared object stores without a host UI ([9cc4700](https://github.com/ReventlessDev/reventless-core/commit/9cc4700dc96f05e3d35fbc912a4c7be7a5531337))
11
+
12
+
13
+ # 3.0.0-alpha.240 (2026-07-28)
14
+
15
+ ### Features
16
+
17
+ * **platform:** provision object stores from the fields that declare them ([b5e2a1e](https://github.com/ReventlessDev/reventless-core/commit/b5e2a1ec88099941c113e4963f9c4b346b96b0d6))
18
+
19
+
6
20
  # 3.0.0-alpha.239 (2026-07-28)
7
21
 
8
22
  **Note:** Version bump only for package @reventlessdev/reventless-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.239",
3
+ "version": "3.0.0-alpha.241",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -13,15 +13,15 @@
13
13
  "uuid": "^13.0.0",
14
14
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.25",
15
15
  "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
16
- "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
17
16
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.61",
18
17
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
19
- "@reventlessdev/reventless-core": "3.0.0-alpha.189",
20
- "@reventlessdev/reventless-infra": "3.0.0-alpha.107",
21
- "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
18
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
22
19
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
23
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.53",
24
- "@reventlessdev/reventless-spec": "3.0.0-alpha.82"
20
+ "@reventlessdev/reventless-core": "3.0.0-alpha.190",
21
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.108",
22
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
23
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.54",
24
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.83"
25
25
  },
26
26
  "devDependencies": {
27
27
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -44,6 +44,40 @@ let splitApiOutputsRef: ref<option<splitApiOutputs>> = ref(None)
44
44
  Call after `makePlatform` — returns `None` in unified mode. */
45
45
  let getSplitApiOutputs = () => splitApiOutputsRef.contents
46
46
 
47
+ // Object stores provisioned from field declarations — populated by
48
+ // deployPlatform. Access via getObjectStoreEndpoints() after it has been called.
49
+ //
50
+ // A platform whose UI ships from its own stack needs each store's presign
51
+ // endpoint, and `deployPlatform` is the only place that knows it. Without this
52
+ // the store is provisioned and unnameable: the stores are built unconditionally,
53
+ // but every consumer of them used to sit inside `switch hostUiBundle`. Same
54
+ // reason `getSplitApiOutputs` exists — a root cannot re-export what it cannot
55
+ // reach.
56
+ type objectStoreEndpoint = {
57
+ // The store's qualified `{plugin}.{store}` name. The same string
58
+ // `pluginStructure.requiredStores` carries, so a UI's binding key and the
59
+ // declaration's identity are one value rather than two that can drift.
60
+ store: string,
61
+ // The path this store's keys are rooted at, and therefore the path it must be
62
+ // served under for a minted `/{key}` ref to resolve.
63
+ keyPrefix: string,
64
+ bucketName: string,
65
+ uploadUrl: Pulumi.Output.t<string>,
66
+ // Public base URL the store is served from, when the platform serves it
67
+ // itself. `None` when a host-UI bundle is deployed — there the shell's own
68
+ // origin serves the store same-origin and a relative `/{prefix}/…` resolves
69
+ // without a base.
70
+ baseUrl: option<Pulumi.Output.t<string>>,
71
+ }
72
+ let objectStoreEndpointsRef: ref<array<objectStoreEndpoint>> = ref([])
73
+
74
+ /** Returns the object stores this platform provisioned from field declarations.
75
+
76
+ Empty when nothing is declared — a legitimate answer rather than a
77
+ call-order mistake, so this returns `[]` instead of throwing the way
78
+ `getApiConfig` does. */
79
+ let getObjectStoreEndpoints = () => objectStoreEndpointsRef.contents
80
+
47
81
  module MakeWithConfig = (
48
82
  Config: {
49
83
  let splitApi: bool
@@ -1046,7 +1080,11 @@ module MakeWithConfig = (
1046
1080
  platformMergeGate: Pulumi.Output.t<unit>,
1047
1081
  }
1048
1082
 
1049
- let deployPlatform = (~version, ~hostUiBundle: option<hostUiBundleConfig>=?) => {
1083
+ let deployPlatform = (
1084
+ ~version,
1085
+ ~hostUiBundle: option<hostUiBundleConfig>=?,
1086
+ ~capabilities: array<ReventlessInfra.Platform.capability>=[],
1087
+ ) => {
1050
1088
  log.info(~comp="Platform:deployPlatform", `v${version}`)
1051
1089
  let scheduler = makeScheduler()
1052
1090
  hooks.scheduler := Some(scheduler)
@@ -1452,6 +1490,205 @@ module MakeWithConfig = (
1452
1490
  })
1453
1491
  })
1454
1492
 
1493
+ // ── Declared object stores ────────────────────────────────────────────
1494
+ //
1495
+ // The store a field declares is the store that gets provisioned. Before
1496
+ // this, a `@storageRef("productImages")` annotation and a hand-written
1497
+ // bucket name were two unrelated strings: deleting the annotation left the
1498
+ // bucket, and adding one provisioned nothing.
1499
+ //
1500
+ // Provisioned here, in the platform deploy, rather than in each plugin's
1501
+ // stack. Three things force it: the serving CDN lives here, the presign
1502
+ // services live here, and a shared-layout bucket has several plugins'
1503
+ // stores in it, so no single plugin stack can own it. Ownership is carried
1504
+ // in the tags instead — a per-store bucket is tagged to the plugin whose
1505
+ // field declared it; a shared bucket is platform substrate, because that is
1506
+ // what it is.
1507
+ let stackName = Pulumi.Pulumi.getStackName()
1508
+ let storeLayout = Util_StoreLayout.layoutFor(
1509
+ ~stack=stackName,
1510
+ ~prodStacks=Util_HostUiDomain.resolveProdStacks(),
1511
+ )
1512
+ let storeProtection = Util_StoreLayout.protectionFor(~stack=stackName)
1513
+
1514
+ // Dedup by `(plugin, store)` — that pair is a store's identity, and many
1515
+ // fields legitimately name one store.
1516
+ let declaredStores =
1517
+ capabilities
1518
+ ->Array.filterMap(c =>
1519
+ switch c {
1520
+ | ObjectStore({plugin, store}) => Some((plugin, store))
1521
+ | Geocoding => None
1522
+ }
1523
+ )
1524
+ ->Array.reduce([], (acc, (plugin, store)) =>
1525
+ acc->Array.some(((p, s)) => p == plugin && s == store)
1526
+ ? acc
1527
+ : Array.concat(acc, [(plugin, store)])
1528
+ )
1529
+
1530
+ if declaredStores->Array.length > 0 {
1531
+ // The fail-open in `layoutFor` is only dangerous while it is silent: a
1532
+ // production stack that is not on the prod list gets the shared layout and
1533
+ // nothing errors. Log the choice on every deploy so it surfaces the first
1534
+ // time it happens rather than at an audit.
1535
+ log.info(
1536
+ ~comp="Platform:deployPlatform",
1537
+ `object stores: ${switch storeLayout {
1538
+ | PerStore => "bucket per store"
1539
+ | SharedBucket => "shared bucket, {store}/… prefixes"
1540
+ }}, ${switch storeProtection {
1541
+ | Protected => "protected"
1542
+ | Unprotected => "unprotected (disposable stack)"
1543
+ }} — stack ${stackName}`,
1544
+ )
1545
+ }
1546
+
1547
+ // One bucket per *physical name*, which is one per store under `PerStore`
1548
+ // and one for the whole stack under `SharedBucket`. Grouped rather than
1549
+ // mapped because those cardinalities differ, and a bucket carries only one
1550
+ // policy and one CDN origin however many stores live in it.
1551
+ let storeBuckets: Dict.t<ReventlessInfra.Platform.objectStore> = Dict.make()
1552
+ let storeBucketOrder: array<string> = []
1553
+ let declaredStoreServices = declaredStores->Array.map(((plugin, store)) => {
1554
+ let bucketName = Util_StoreLayout.bucketNameFor(
1555
+ ~layout=storeLayout,
1556
+ ~stack=stackName,
1557
+ ~plugin,
1558
+ ~store,
1559
+ )
1560
+ let keyPrefix = Util_StoreLayout.keyPrefixFor(~store)
1561
+ let bucket = switch storeBuckets->Dict.get(bucketName) {
1562
+ | Some(b) => b
1563
+ | None =>
1564
+ let b = Capability_ObjectStore_S3.make(
1565
+ ~name=bucketName,
1566
+ ~keyPrefix,
1567
+ // A shared bucket belongs to no single plugin; a dedicated one does.
1568
+ ~plugin=?switch storeLayout {
1569
+ | PerStore => Some(plugin)
1570
+ | SharedBucket => None
1571
+ },
1572
+ ~protect=storeProtection == Protected,
1573
+ // A protected bucket blocks `pulumi destroy`, so protecting a
1574
+ // disposable stack would leak exactly the buckets sharing exists to
1575
+ // save. The two settings are opposite faces of one decision.
1576
+ ~forceDestroy=storeProtection == Unprotected,
1577
+ )
1578
+ storeBuckets->Dict.set(bucketName, b)
1579
+ storeBucketOrder->Array.push(bucketName)
1580
+ b
1581
+ }
1582
+ let storeHandle = bucket->Capability_ObjectStore_S3.underPrefix(~keyPrefix)
1583
+ // One presign service per store, scoped to that store's own prefix.
1584
+ let presign = Upload_Presign_S3.make(
1585
+ ~name=`UploadPresign-${plugin}-${store}`,
1586
+ ~bucketName=storeHandle.bucketName,
1587
+ ~servedPrefix=storeHandle.keyPrefix,
1588
+ )
1589
+ (`${plugin}.${store}`, keyPrefix, bucketName, presign.url)
1590
+ })
1591
+
1592
+ // Group the served view by bucket: one origin and one bucket policy per
1593
+ // bucket, carrying every prefix served from it.
1594
+ let declaredServedBuckets = storeBucketOrder->Array.filterMap(bucketName =>
1595
+ storeBuckets
1596
+ ->Dict.get(bucketName)
1597
+ ->Option.map(b => {
1598
+ ReventlessInfra.Platform.id: bucketName,
1599
+ prefixes: declaredStoreServices
1600
+ ->Array.filterMap(((_, prefix, bn, _)) => bn == bucketName ? Some(prefix) : None),
1601
+ bucketId: b.bucketId,
1602
+ bucketArn: b.bucketArn,
1603
+ bucketRegionalDomainName: b.bucketRegionalDomainName,
1604
+ })
1605
+ )
1606
+
1607
+ // ── Serving the declared stores ───────────────────────────────────────
1608
+ //
1609
+ // A store's bucket blocks public policy and takes its read grant only from
1610
+ // a distribution's BucketPolicy, so "provisioned" and "readable" are two
1611
+ // different things. When a host UI bundle is deployed, its distribution
1612
+ // fronts the stores same-origin (below) and a relative `/{prefix}/…`
1613
+ // resolves with no base URL. When it is not, the platform fronts them
1614
+ // itself — otherwise every declared store is write-only.
1615
+ //
1616
+ // Never both: two distributions fronting one bucket would each write the
1617
+ // bucket's single allowed policy and silently unpick the other's grant.
1618
+ // `Util_StoreLayout.servingFor` is where that exclusion is decided, so it is
1619
+ // one testable choice rather than a condition spelled out here.
1620
+ let storeServingBaseUrl: option<Pulumi.Output.t<string>> = switch Util_StoreLayout.servingFor(
1621
+ ~hasHostUiBundle=hostUiBundle->Option.isSome,
1622
+ ~declaredBucketCount=declaredServedBuckets->Array.length,
1623
+ ) {
1624
+ | NoStores | HostShell => None
1625
+ | PlatformOwned =>
1626
+ Some(
1627
+ Plugin_Stack.makeServedBucketDistribution(
1628
+ ~name="object-stores",
1629
+ ~servedBuckets=declaredServedBuckets,
1630
+ ),
1631
+ )
1632
+ }
1633
+
1634
+ let declaredStoreEndpoints = declaredStoreServices->Array.map(((
1635
+ store,
1636
+ keyPrefix,
1637
+ bucketName,
1638
+ uploadUrl,
1639
+ )) => {store, keyPrefix, bucketName, uploadUrl, baseUrl: storeServingBaseUrl})
1640
+ objectStoreEndpointsRef := declaredStoreEndpoints
1641
+
1642
+ // Exported unconditionally — the provisioning above already is. Their only
1643
+ // consumers used to sit inside `switch hostUiBundle`, which left a platform
1644
+ // whose UI ships from another stack holding stores it could not name.
1645
+ //
1646
+ // Omitted entirely when nothing is declared, so a deployment with no
1647
+ // declared store keeps a byte-identical output set.
1648
+ if declaredStoreEndpoints->Array.length > 0 {
1649
+ Pulumi.Pulumi.export(
1650
+ "uploadEndpoints",
1651
+ declaredStoreEndpoints
1652
+ ->Array.map(e => e.uploadUrl->Pulumi.Output.apply(u => (e.store, JSON.Encode.string(u))))
1653
+ ->Pulumi.Output.all
1654
+ ->Pulumi.Output.apply(pairs => pairs->Dict.fromArray->JSON.Encode.object),
1655
+ )
1656
+ Pulumi.Pulumi.export(
1657
+ "objectStores",
1658
+ declaredStoreEndpoints
1659
+ ->Array.map(e =>
1660
+ switch e.baseUrl {
1661
+ | Some(u) => u->Pulumi.Output.apply(b => (e, Some(b)))
1662
+ | None => Pulumi.Output.make((e, None))
1663
+ }
1664
+ )
1665
+ ->Pulumi.Output.all
1666
+ ->Pulumi.Output.apply(pairs =>
1667
+ pairs
1668
+ ->Array.map(((e, baseUrl)) => (
1669
+ e.store,
1670
+ [
1671
+ ("bucketName", JSON.Encode.string(e.bucketName)),
1672
+ ("keyPrefix", JSON.Encode.string(e.keyPrefix)),
1673
+ ]
1674
+ // Absent when the host shell serves the store: there the object is
1675
+ // addressable same-origin and a base URL would be a second, and
1676
+ // wrong, way to reach it.
1677
+ ->Array.concat(
1678
+ switch baseUrl {
1679
+ | Some(b) => [("baseUrl", JSON.Encode.string(b))]
1680
+ | None => []
1681
+ },
1682
+ )
1683
+ ->Dict.fromArray
1684
+ ->JSON.Encode.object,
1685
+ ))
1686
+ ->Dict.fromArray
1687
+ ->JSON.Encode.object
1688
+ ),
1689
+ )
1690
+ }
1691
+
1455
1692
  // Host UI shell deployment — opt-in via ~hostUiBundle. The shell SPA is
1456
1693
  // hosted on its own CloudFront distribution; `config.json` is generated
1457
1694
  // at deploy time with the resolved API endpoints, region, and Cognito
@@ -1494,17 +1731,27 @@ module MakeWithConfig = (
1494
1731
  // roots its keys at, read from the service's own constant. This is the
1495
1732
  // whole reason the store arrives as one value: the app no longer restates
1496
1733
  // the prefix, so it cannot restate it wrongly.
1497
- let servedBuckets = switch cfg.uploadBucket {
1498
- | Some(store) => [
1499
- {
1500
- ReventlessInfra.Platform.prefix: Upload_Presign_S3.defaultServedPrefix,
1501
- bucketId: store.bucketId,
1502
- bucketArn: store.bucketArn,
1503
- bucketRegionalDomainName: store.bucketRegionalDomainName,
1504
- },
1505
- ]
1506
- | None => []
1507
- }
1734
+ //
1735
+ // The legacy hand-configured store is served alongside the declared ones,
1736
+ // not replaced by them. Refs live in an append-only event log, so the
1737
+ // `/uploads/…` values already written there can never be rewritten;
1738
+ // `uploads` is therefore treated as a store that happens to predate the
1739
+ // declaration, and keeps its prefix permanently.
1740
+ let servedBuckets = Array.concat(
1741
+ switch cfg.uploadBucket {
1742
+ | Some(store) => [
1743
+ {
1744
+ ReventlessInfra.Platform.id: store.keyPrefix,
1745
+ prefixes: [store.keyPrefix],
1746
+ bucketId: store.bucketId,
1747
+ bucketArn: store.bucketArn,
1748
+ bucketRegionalDomainName: store.bucketRegionalDomainName,
1749
+ },
1750
+ ]
1751
+ | None => []
1752
+ },
1753
+ declaredServedBuckets,
1754
+ )
1508
1755
 
1509
1756
  let {distributionUrl, bucketName} = Plugin_Stack.makeUiBundleDistribution(
1510
1757
  ~pluginId="host-ui",
@@ -1569,6 +1816,18 @@ module MakeWithConfig = (
1569
1816
  | None => Pulumi.Output.make(None)
1570
1817
  }
1571
1818
 
1819
+ // Per-store presign endpoints, keyed by the store's qualified name. This
1820
+ // is what lets an upload input bind to the store its field *declares*
1821
+ // rather than inferring one from the field's name — the endpoint exists
1822
+ // per store, so there is something concrete to bind to.
1823
+ //
1824
+ // `uploadEndpoint` (singular) stays as the legacy service's URL: a shell
1825
+ // built before per-store binding reads it and is unaffected.
1826
+ let storeUploadEndpointsOutput: Pulumi.Output.t<array<(string, string)>> =
1827
+ declaredStoreServices
1828
+ ->Array.map(((qualified, _, _, url)) => url->Pulumi.Output.apply(u => (qualified, u)))
1829
+ ->Pulumi.Output.all
1830
+
1572
1831
  let configJsonContent =
1573
1832
  (
1574
1833
  (
@@ -1579,14 +1838,14 @@ module MakeWithConfig = (
1579
1838
  )->Pulumi.Output.all4,
1580
1839
  domainEventsEndpointOutput,
1581
1840
  geocoderEndpointOutput,
1582
- uploadEndpointOutput,
1841
+ (uploadEndpointOutput, storeUploadEndpointsOutput)->Pulumi.Output.all2,
1583
1842
  )
1584
1843
  ->Pulumi.Output.all4
1585
1844
  ->Pulumi.Output.apply(((
1586
1845
  (domainEp, platformEp, poolId, clientId),
1587
1846
  eventsEpOpt,
1588
1847
  geocoderEpOpt,
1589
- uploadEpOpt,
1848
+ (uploadEpOpt, storeUploadEps),
1590
1849
  )) => {
1591
1850
  let fields = [
1592
1851
  ("apiEndpoint", JSON.Encode.string(domainEp)),
@@ -1623,7 +1882,25 @@ module MakeWithConfig = (
1623
1882
  | Some(ep) => Array.concat(withGeocoder, [("uploadEndpoint", JSON.Encode.string(ep))])
1624
1883
  | None => withGeocoder
1625
1884
  }
1626
- withUpload->Dict.fromArray->JSON.Encode.object->JSON.stringify
1885
+ // Omitted entirely when nothing is declared, so a deployment without
1886
+ // declared stores writes a byte-identical config.json.
1887
+ let withStoreUploads = switch storeUploadEps {
1888
+ | [] => withUpload
1889
+ | eps =>
1890
+ Array.concat(
1891
+ withUpload,
1892
+ [
1893
+ (
1894
+ "uploadEndpoints",
1895
+ eps
1896
+ ->Array.map(((qualified, url)) => (qualified, JSON.Encode.string(url)))
1897
+ ->Dict.fromArray
1898
+ ->JSON.Encode.object,
1899
+ ),
1900
+ ],
1901
+ )
1902
+ }
1903
+ withStoreUploads->Dict.fromArray->JSON.Encode.object->JSON.stringify
1627
1904
  })
1628
1905
 
1629
1906
  let _ = PulumiAws.S3.BucketObject.make(