@openlup/platform 0.0.0-stage → 0.15.0

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.
Files changed (160) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +204 -0
  3. package/README.md +125 -2
  4. package/dist/bff/dispatch.d.ts +27 -0
  5. package/dist/bff/dispatch.js +47 -0
  6. package/dist/bff/envelope.d.ts +46 -0
  7. package/dist/bff/envelope.js +80 -0
  8. package/dist/bff/health.d.ts +24 -0
  9. package/dist/bff/health.js +19 -0
  10. package/dist/bff/index.d.ts +6 -0
  11. package/dist/bff/index.js +6 -0
  12. package/dist/bff/lazyRoute.d.ts +4 -0
  13. package/dist/bff/lazyRoute.js +13 -0
  14. package/dist/bff/requestContext.d.ts +17 -0
  15. package/dist/bff/requestContext.js +26 -0
  16. package/dist/bff/routerRewrite.d.ts +2 -0
  17. package/dist/bff/routerRewrite.js +31 -0
  18. package/dist/blob/binding.d.ts +16 -0
  19. package/dist/blob/binding.js +37 -0
  20. package/dist/blob/filesystem.d.ts +8 -0
  21. package/dist/blob/filesystem.js +71 -0
  22. package/dist/blob/index.d.ts +6 -0
  23. package/dist/blob/index.js +6 -0
  24. package/dist/blob/s3.d.ts +32 -0
  25. package/dist/blob/s3.js +56 -0
  26. package/dist/blob/s3Client.d.ts +25 -0
  27. package/dist/blob/s3Client.js +76 -0
  28. package/dist/blob/supabase.d.ts +40 -0
  29. package/dist/blob/supabase.js +59 -0
  30. package/dist/blob/vercel.d.ts +63 -0
  31. package/dist/blob/vercel.js +69 -0
  32. package/dist/composition/composeBundle.d.ts +47 -0
  33. package/dist/composition/composeBundle.js +45 -0
  34. package/dist/composition/httpBinding.d.ts +8 -0
  35. package/dist/composition/httpBinding.js +18 -0
  36. package/dist/composition/index.d.ts +3 -0
  37. package/dist/composition/index.js +3 -0
  38. package/dist/composition/nodeHost.d.ts +103 -0
  39. package/dist/composition/nodeHost.js +145 -0
  40. package/dist/data/binding.d.ts +34 -0
  41. package/dist/data/binding.js +92 -0
  42. package/dist/data/index.d.ts +5 -0
  43. package/dist/data/index.js +5 -0
  44. package/dist/data/postgresGateway.d.ts +42 -0
  45. package/dist/data/postgresGateway.js +135 -0
  46. package/dist/data/queryBuilder.d.ts +54 -0
  47. package/dist/data/queryBuilder.js +240 -0
  48. package/dist/data/queryBuilderSql.d.ts +76 -0
  49. package/dist/data/queryBuilderSql.js +145 -0
  50. package/dist/data/supabaseClientFactory.d.ts +34 -0
  51. package/dist/data/supabaseClientFactory.js +56 -0
  52. package/dist/data/supabaseGateway.d.ts +22 -0
  53. package/dist/data/supabaseGateway.js +49 -0
  54. package/dist/http/deadline.d.ts +20 -0
  55. package/dist/http/deadline.js +93 -0
  56. package/dist/http/index.d.ts +6 -0
  57. package/dist/http/index.js +5 -0
  58. package/dist/http/requestAdapter.d.ts +38 -0
  59. package/dist/http/requestAdapter.js +203 -0
  60. package/dist/http/runtime.d.ts +83 -0
  61. package/dist/http/runtime.js +199 -0
  62. package/dist/http/security.d.ts +44 -0
  63. package/dist/http/security.js +114 -0
  64. package/dist/http/staticAssets.d.ts +3 -0
  65. package/dist/http/staticAssets.js +42 -0
  66. package/dist/http/types.d.ts +23 -0
  67. package/dist/http/types.js +6 -0
  68. package/dist/job-ledger/index.d.ts +5 -0
  69. package/dist/job-ledger/index.js +3 -0
  70. package/dist/job-ledger/ports.d.ts +10 -0
  71. package/dist/job-ledger/ports.js +7 -0
  72. package/dist/job-ledger/postgres.d.ts +10 -0
  73. package/dist/job-ledger/postgres.js +101 -0
  74. package/dist/job-ledger/sql.d.ts +7 -0
  75. package/dist/job-ledger/sql.js +10 -0
  76. package/dist/job-ledger/supabase.d.ts +77 -0
  77. package/dist/job-ledger/supabase.js +202 -0
  78. package/dist/kernel/index.d.ts +1 -0
  79. package/dist/kernel/index.js +1 -0
  80. package/dist/kernel/platformKernel.d.ts +34 -0
  81. package/dist/kernel/platformKernel.js +115 -0
  82. package/dist/migrations/index.d.ts +1 -0
  83. package/dist/migrations/index.js +1 -0
  84. package/dist/migrations/postgresRunner.d.ts +50 -0
  85. package/dist/migrations/postgresRunner.js +176 -0
  86. package/dist/scheduler/advisoryLock.d.ts +44 -0
  87. package/dist/scheduler/advisoryLock.js +114 -0
  88. package/dist/scheduler/cronGuard.d.ts +10 -0
  89. package/dist/scheduler/cronGuard.js +19 -0
  90. package/dist/scheduler/cronSecret.d.ts +5 -0
  91. package/dist/scheduler/cronSecret.js +23 -0
  92. package/dist/scheduler/handlers.d.ts +9 -0
  93. package/dist/scheduler/handlers.js +10 -0
  94. package/dist/scheduler/httpEntry.d.ts +16 -0
  95. package/dist/scheduler/httpEntry.js +31 -0
  96. package/dist/scheduler/index.d.ts +9 -0
  97. package/dist/scheduler/index.js +9 -0
  98. package/dist/scheduler/jobRegistry.d.ts +21 -0
  99. package/dist/scheduler/jobRegistry.js +39 -0
  100. package/dist/scheduler/jobs.d.ts +48 -0
  101. package/dist/scheduler/jobs.js +92 -0
  102. package/dist/scheduler/nodeScheduler.d.ts +42 -0
  103. package/dist/scheduler/nodeScheduler.js +73 -0
  104. package/dist/scheduler/vercelScheduler.d.ts +29 -0
  105. package/dist/scheduler/vercelScheduler.js +51 -0
  106. package/package.json +108 -4
  107. package/release-gates.json +121 -0
  108. package/sql/20260809150000_job_run_ledger.sql +301 -0
  109. package/sql/20260809150001_job_run_ledger_v3.sql +70 -0
  110. package/src/bff/dispatch.ts +81 -0
  111. package/src/bff/envelope.ts +142 -0
  112. package/src/bff/health.ts +37 -0
  113. package/src/bff/index.ts +30 -0
  114. package/src/bff/lazyRoute.ts +28 -0
  115. package/src/bff/requestContext.ts +48 -0
  116. package/src/bff/routerRewrite.ts +38 -0
  117. package/src/blob/binding.ts +55 -0
  118. package/src/blob/filesystem.ts +86 -0
  119. package/src/blob/index.ts +17 -0
  120. package/src/blob/s3.ts +87 -0
  121. package/src/blob/s3Client.ts +91 -0
  122. package/src/blob/supabase.ts +88 -0
  123. package/src/blob/vercel.ts +127 -0
  124. package/src/composition/composeBundle.ts +109 -0
  125. package/src/composition/httpBinding.ts +30 -0
  126. package/src/composition/index.ts +17 -0
  127. package/src/composition/nodeHost.ts +289 -0
  128. package/src/data/binding.ts +157 -0
  129. package/src/data/index.ts +33 -0
  130. package/src/data/postgresGateway.ts +186 -0
  131. package/src/data/queryBuilder.ts +300 -0
  132. package/src/data/queryBuilderSql.ts +183 -0
  133. package/src/data/supabaseClientFactory.ts +92 -0
  134. package/src/data/supabaseGateway.ts +76 -0
  135. package/src/http/deadline.ts +117 -0
  136. package/src/http/index.ts +36 -0
  137. package/src/http/requestAdapter.ts +215 -0
  138. package/src/http/runtime.ts +319 -0
  139. package/src/http/security.ts +155 -0
  140. package/src/http/staticAssets.ts +42 -0
  141. package/src/http/types.ts +31 -0
  142. package/src/job-ledger/index.ts +25 -0
  143. package/src/job-ledger/ports.ts +27 -0
  144. package/src/job-ledger/postgres.ts +121 -0
  145. package/src/job-ledger/sql.ts +12 -0
  146. package/src/job-ledger/supabase.ts +294 -0
  147. package/src/kernel/index.ts +17 -0
  148. package/src/kernel/platformKernel.ts +153 -0
  149. package/src/migrations/index.ts +10 -0
  150. package/src/migrations/postgresRunner.ts +255 -0
  151. package/src/scheduler/advisoryLock.ts +145 -0
  152. package/src/scheduler/cronGuard.ts +30 -0
  153. package/src/scheduler/cronSecret.ts +26 -0
  154. package/src/scheduler/handlers.ts +24 -0
  155. package/src/scheduler/httpEntry.ts +49 -0
  156. package/src/scheduler/index.ts +47 -0
  157. package/src/scheduler/jobRegistry.ts +57 -0
  158. package/src/scheduler/jobs.ts +119 -0
  159. package/src/scheduler/nodeScheduler.ts +123 -0
  160. package/src/scheduler/vercelScheduler.ts +71 -0
package/src/blob/s3.ts ADDED
@@ -0,0 +1,87 @@
1
+ // S3-compatible adapter for the platform-runtime BlobStoragePort.
2
+ //
3
+ // Targets any S3-compatible object store (AWS S3, MinIO, R2, Spaces) used by a self-hosted
4
+ // node-postgres bundle. To stay credential-free in CI and avoid pulling a new SDK dependency
5
+ // into this wave, it depends on a minimal STRUCTURAL client interface (PutObject/HeadObject/
6
+ // DeleteObject/ListObjectsV2). A thin factory over `@aws-sdk/client-s3` satisfies this shape;
7
+ // tests inject a fake. URLs are derived from a configurable public base (CDN or path-style).
8
+
9
+ import type { BlobStoragePort } from "@openlup/core/platform-runtime";
10
+
11
+ export interface S3ObjectClient {
12
+ putObject(input: {
13
+ Bucket: string;
14
+ Key: string;
15
+ Body: Uint8Array | Buffer;
16
+ ContentType?: string;
17
+ }): Promise<unknown>;
18
+ headObject(input: { Bucket: string; Key: string }): Promise<unknown>;
19
+ deleteObject(input: { Bucket: string; Key: string }): Promise<unknown>;
20
+ listObjectsV2(input: {
21
+ Bucket: string;
22
+ Prefix?: string;
23
+ }): Promise<{ Contents?: Array<{ Key?: string }> }>;
24
+ }
25
+
26
+ export interface S3BlobOptions {
27
+ bucket: string;
28
+ /** Public base URL for objects, e.g. https://cdn.example.com or https://s3.region.amazonaws.com/bucket. */
29
+ baseUrl: string;
30
+ defaultContentType?: string;
31
+ }
32
+
33
+ export function createS3BlobStorage(client: S3ObjectClient, options: S3BlobOptions): BlobStoragePort {
34
+ const bucket = options.bucket;
35
+ const baseUrl = options.baseUrl.replace(/\/$/, "");
36
+ const defaultContentType = options.defaultContentType ?? "application/octet-stream";
37
+
38
+ return {
39
+ urlFor(key: string): string {
40
+ return `${baseUrl}/${normalizeKey(key)}`;
41
+ },
42
+
43
+ async upload(key, data, opts) {
44
+ const objectKey = normalizeKey(key);
45
+ await client.putObject({
46
+ Bucket: bucket,
47
+ Key: objectKey,
48
+ Body: data,
49
+ ContentType: opts?.contentType ?? defaultContentType,
50
+ });
51
+ return { url: this.urlFor(objectKey), pathname: objectKey };
52
+ },
53
+
54
+ async exists(key) {
55
+ try {
56
+ await client.headObject({ Bucket: bucket, Key: normalizeKey(key) });
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ },
62
+
63
+ async delete(keyOrUrl) {
64
+ await client.deleteObject({ Bucket: bucket, Key: stripBaseUrl(keyOrUrl, baseUrl) });
65
+ },
66
+
67
+ async list(prefix) {
68
+ const result = await client.listObjectsV2({
69
+ Bucket: bucket,
70
+ Prefix: prefix ? normalizeKey(prefix) : undefined,
71
+ });
72
+ const keys = (result.Contents ?? [])
73
+ .map((entry) => entry.Key)
74
+ .filter((key): key is string => typeof key === "string")
75
+ .sort();
76
+ return { keys };
77
+ },
78
+ };
79
+ }
80
+
81
+ function normalizeKey(key: string): string {
82
+ return key.replace(/^\/+/, "");
83
+ }
84
+
85
+ function stripBaseUrl(keyOrUrl: string, baseUrl: string): string {
86
+ return keyOrUrl.startsWith(`${baseUrl}/`) ? normalizeKey(keyOrUrl.slice(baseUrl.length + 1)) : normalizeKey(keyOrUrl);
87
+ }
@@ -0,0 +1,91 @@
1
+ // Default S3 client for the S3-compatible blob capability, built from the environment.
2
+ //
3
+ // `@aws-sdk/client-s3` is an optional peer: it is loaded on the first storage call, through Node's
4
+ // own module resolution, so an application that never selects S3 needs neither the SDK nor its
5
+ // settings. Settings:
6
+ // BLOB_S3_REGION (default us-east-1), BLOB_S3_ENDPOINT (an S3-compatible service; path-style
7
+ // addressing follows from BLOB_S3_FORCE_PATH_STYLE=true), and BLOB_S3_ACCESS_KEY_ID with
8
+ // BLOB_S3_SECRET_ACCESS_KEY (both, or neither to use the SDK's default credential chain).
9
+
10
+ import { createRequire } from "node:module";
11
+
12
+ import type { S3ObjectClient } from "./s3.js";
13
+
14
+ type Env = Record<string, string | undefined>;
15
+ export interface S3Sdk {
16
+ S3Client: new (config: {
17
+ region: string;
18
+ endpoint?: string;
19
+ forcePathStyle: boolean;
20
+ credentials?: { accessKeyId: string; secretAccessKey: string };
21
+ }) => { send(command: unknown): ReturnType<S3ObjectClient["listObjectsV2"]> };
22
+ PutObjectCommand: new (input: Parameters<S3ObjectClient["putObject"]>[0]) => unknown;
23
+ HeadObjectCommand: new (input: Parameters<S3ObjectClient["headObject"]>[0]) => unknown;
24
+ DeleteObjectCommand: new (input: Parameters<S3ObjectClient["deleteObject"]>[0]) => unknown;
25
+ ListObjectsV2Command: new (input: Parameters<S3ObjectClient["listObjectsV2"]>[0]) => unknown;
26
+ }
27
+
28
+ function setting(env: Env, name: string): string | undefined {
29
+ const value = env[name]?.trim();
30
+ return value ? value : undefined;
31
+ }
32
+
33
+ function loadS3Sdk(): S3Sdk {
34
+ return createRequire(import.meta.url)("@aws-sdk/client-s3") as S3Sdk;
35
+ }
36
+
37
+ /**
38
+ * Build an `S3ObjectClient` whose SDK client is created lazily from the given environment.
39
+ * `loadSdk` replaces the SDK loader (tests).
40
+ */
41
+ export function createS3ClientFromEnv(env: Env, loadSdk: () => S3Sdk = loadS3Sdk): S3ObjectClient {
42
+ const accessKeyId = setting(env, "BLOB_S3_ACCESS_KEY_ID");
43
+ const secretAccessKey = setting(env, "BLOB_S3_SECRET_ACCESS_KEY");
44
+ if ((accessKeyId === undefined) !== (secretAccessKey === undefined)) {
45
+ throw new Error("blob_s3_credentials_incomplete");
46
+ }
47
+ let loaded: { sdk: S3Sdk; client: InstanceType<S3Sdk["S3Client"]> } | null = null;
48
+ const connect = async () => {
49
+ if (loaded) return loaded;
50
+ let sdk: S3Sdk;
51
+ try { sdk = loadSdk(); } catch (cause) { peerMissing(cause); }
52
+ loaded = {
53
+ sdk,
54
+ client: new sdk.S3Client({
55
+ region: setting(env, "BLOB_S3_REGION") ?? "us-east-1",
56
+ endpoint: setting(env, "BLOB_S3_ENDPOINT"),
57
+ forcePathStyle: setting(env, "BLOB_S3_FORCE_PATH_STYLE") === "true",
58
+ credentials: accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined,
59
+ }),
60
+ };
61
+ return loaded;
62
+ };
63
+ return {
64
+ async putObject(input) {
65
+ const { sdk, client } = await connect();
66
+ return client.send(new sdk.PutObjectCommand(input));
67
+ },
68
+ async headObject(input) {
69
+ const { sdk, client } = await connect();
70
+ return client.send(new sdk.HeadObjectCommand(input));
71
+ },
72
+ async deleteObject(input) {
73
+ const { sdk, client } = await connect();
74
+ return client.send(new sdk.DeleteObjectCommand(input));
75
+ },
76
+ async listObjectsV2(input) {
77
+ const { sdk, client } = await connect();
78
+ return client.send(new sdk.ListObjectsV2Command(input));
79
+ },
80
+ };
81
+ }
82
+
83
+ /** Translate only absence of this optional peer; preserve broken SDKs and other failures. */
84
+ function peerMissing(cause: unknown): never {
85
+ if (cause instanceof Error && "code" in cause
86
+ && (cause.code === "ERR_MODULE_NOT_FOUND" || cause.code === "MODULE_NOT_FOUND")
87
+ && /^Cannot find (?:package|module) ['"]@aws-sdk\/client-s3['"]/.test(cause.message)) {
88
+ throw new Error("OPENLUP_E_PEER_MISSING: @aws-sdk/client-s3 is required by the S3 blob capability; install it alongside @openlup/platform", { cause });
89
+ }
90
+ throw cause;
91
+ }
@@ -0,0 +1,88 @@
1
+ // Supabase Storage adapter for the platform-runtime BlobStoragePort.
2
+ //
3
+ // Wraps `client.storage.from(bucket)` 1:1 with the existing application storage usage:
4
+ // upsert uploads, public-URL resolution via getPublicUrl, and bucket-scoped list/remove. Keys
5
+ // are bucket-relative paths. Behavior matches the current inline storage calls so the default
6
+ // vercel-supabase bundle (which uses this for storage buckets) is byte-identical.
7
+
8
+ import type { BlobStoragePort } from "@openlup/core/platform-runtime";
9
+
10
+ /** Minimal structural shape of the Supabase Storage bucket API we depend on. */
11
+ export interface SupabaseStorageBucket {
12
+ upload(
13
+ path: string,
14
+ bytes: Uint8Array | Buffer,
15
+ options?: { contentType?: string; upsert?: boolean },
16
+ ): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
17
+ remove(paths: string[]): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
18
+ list(
19
+ prefix?: string,
20
+ ): PromiseLike<{ data: Array<{ name: string }> | null; error: { message?: string } | null }>;
21
+ getPublicUrl(path: string): { data: { publicUrl: string } };
22
+ }
23
+
24
+ export interface SupabaseStorageClient {
25
+ storage: { from(bucket: string): SupabaseStorageBucket };
26
+ }
27
+
28
+ export function createSupabaseBlobStorage(
29
+ client: SupabaseStorageClient,
30
+ bucket: string,
31
+ options: { defaultContentType?: string } = {},
32
+ ): BlobStoragePort {
33
+ const from = (): SupabaseStorageBucket => client.storage.from(bucket);
34
+ const defaultContentType = options.defaultContentType ?? "application/octet-stream";
35
+
36
+ return {
37
+ urlFor(key: string): string {
38
+ return from().getPublicUrl(key).data.publicUrl;
39
+ },
40
+
41
+ async upload(key, data, opts) {
42
+ const bucketApi = from();
43
+ const result = await bucketApi.upload(key, data, {
44
+ contentType: opts?.contentType ?? defaultContentType,
45
+ upsert: true,
46
+ });
47
+ if (result.error) {
48
+ throw new Error(`supabase_storage_upload_failed:${result.error.message ?? "unknown"}`);
49
+ }
50
+ return { url: bucketApi.getPublicUrl(key).data.publicUrl, pathname: key };
51
+ },
52
+
53
+ async exists(key) {
54
+ // Supabase has no head(); list the parent folder and match the leaf name.
55
+ const slash = key.lastIndexOf("/");
56
+ const folder = slash >= 0 ? key.slice(0, slash) : "";
57
+ const leaf = slash >= 0 ? key.slice(slash + 1) : key;
58
+ const result = await from().list(folder || undefined);
59
+ if (result.error) return false;
60
+ return (result.data ?? []).some((entry) => entry.name === leaf);
61
+ },
62
+
63
+ async delete(keyOrUrl) {
64
+ const result = await from().remove([toBucketKey(keyOrUrl, bucket)]);
65
+ if (result.error) {
66
+ throw new Error(`supabase_storage_remove_failed:${result.error.message ?? "unknown"}`);
67
+ }
68
+ },
69
+
70
+ async list(prefix) {
71
+ const result = await from().list(prefix);
72
+ if (result.error) {
73
+ throw new Error(`supabase_storage_list_failed:${result.error.message ?? "unknown"}`);
74
+ }
75
+ const base = prefix ? `${prefix.replace(/\/$/, "")}/` : "";
76
+ return { keys: (result.data ?? []).map((entry) => `${base}${entry.name}`) };
77
+ },
78
+ };
79
+ }
80
+
81
+ // Accept either a bucket-relative key or a full public URL; strip everything up to and
82
+ // including the bucket segment so remove() always gets a bucket-relative path.
83
+ function toBucketKey(keyOrUrl: string, bucket: string): string {
84
+ const marker = `/storage/v1/object/public/${bucket}/`;
85
+ const idx = keyOrUrl.indexOf(marker);
86
+ if (idx >= 0) return keyOrUrl.slice(idx + marker.length);
87
+ return keyOrUrl;
88
+ }
@@ -0,0 +1,127 @@
1
+ // Vercel Blob adapter for the platform-runtime BlobStoragePort.
2
+ //
3
+ // Wraps the CURRENT `@vercel/blob` behavior 1:1 so the default vercel-supabase bundle is
4
+ // byte-identical: public access, image content-type pass-through, no random suffix, the same
5
+ // 30-day cache-control, O(1) head() existence checks, and the deterministic public CDN URL
6
+ // derived from the store id encoded in BLOB_READ_WRITE_TOKEN. Operates on FULL keys
7
+ // (pathnames); any key prefix or suffix stays in the consuming facade.
8
+
9
+ import type { BlobStoragePort } from "@openlup/core/platform-runtime";
10
+
11
+ /**
12
+ * The `@vercel/blob` functions this adapter calls, supplied by the application (pass the module
13
+ * itself). The SDK reads its own credentials from the environment, as before.
14
+ */
15
+ export interface VercelBlobSdk {
16
+ put(
17
+ pathname: string,
18
+ body: Buffer,
19
+ options: { access: "public"; contentType: string; addRandomSuffix: boolean; cacheControlMaxAge: number },
20
+ ): Promise<{ url: string; pathname: string }>;
21
+ head(url: string): Promise<{ url?: string } | null | undefined>;
22
+ del(url: string): Promise<unknown>;
23
+ list(options: { prefix?: string }): Promise<{ blobs: Array<{ pathname: string; url: string; uploadedAt: Date }> }>;
24
+ }
25
+
26
+ const DEFAULT_CACHE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
27
+
28
+ export interface VercelBlobAdapterOptions {
29
+ /** The `@vercel/blob` module. */
30
+ sdk: VercelBlobSdk;
31
+ /** Environment whose BLOB_READ_WRITE_TOKEN is read at each call when `token` is absent. */
32
+ env: Record<string, string | undefined>;
33
+ /**
34
+ * Explicit token for deriving the public store URL (`urlFor`, `exists`); takes precedence over
35
+ * `env`. Upload, list and delete use the credentials the injected SDK reads itself, so `env` and
36
+ * `token` must name the same store as the SDK's own configuration.
37
+ */
38
+ token?: string;
39
+ /** Default content-type when an upload omits one (image/png when absent). */
40
+ defaultContentType?: string;
41
+ cacheControlMaxAge?: number;
42
+ }
43
+
44
+ /** A listed blob plus the Vercel-specific metadata (uploadedAt) used by TTL cleanup. */
45
+ export interface VercelBlobEntry {
46
+ pathname: string;
47
+ url: string;
48
+ uploadedAt: Date;
49
+ }
50
+
51
+ /**
52
+ * Vercel-specific extension over the neutral BlobStoragePort.list(): also surfaces uploadedAt
53
+ * so the TTL cron can prune by age. Not part of the port contract (other backends carry no
54
+ * equivalent metadata); consumers needing it depend on this adapter shape explicitly.
55
+ */
56
+ export interface VercelBlobStorage extends BlobStoragePort {
57
+ listDetailed(prefix?: string): Promise<{ blobs: VercelBlobEntry[] }>;
58
+ }
59
+
60
+ /** Parse the public store id from a `vercel_blob_rw_{storeId}_{secret}` token. */
61
+ export function parseVercelStoreId(token: string | undefined): string {
62
+ if (!token) throw new Error("BLOB_READ_WRITE_TOKEN missing");
63
+ const m = token.match(/^vercel_blob_rw_([a-zA-Z0-9]+)_/);
64
+ if (!m) throw new Error("invalid BLOB_READ_WRITE_TOKEN format");
65
+ return m[1]!.toLowerCase();
66
+ }
67
+
68
+ export function createVercelBlobStorage(
69
+ options: VercelBlobAdapterOptions,
70
+ ): VercelBlobStorage {
71
+ const resolveToken = (): string | undefined => options.token ?? options.env.BLOB_READ_WRITE_TOKEN;
72
+ const cacheControlMaxAge = options.cacheControlMaxAge ?? DEFAULT_CACHE_MAX_AGE_SECONDS;
73
+ const defaultContentType = options.defaultContentType ?? "image/png";
74
+
75
+ return {
76
+ urlFor(key: string): string {
77
+ const storeId = parseVercelStoreId(resolveToken());
78
+ return `https://${storeId}.public.blob.vercel-storage.com/${key}`;
79
+ },
80
+
81
+ async upload(key, data, opts) {
82
+ const result = await options.sdk.put(key, toUploadBody(data), {
83
+ access: "public",
84
+ contentType: opts?.contentType ?? defaultContentType,
85
+ addRandomSuffix: false,
86
+ cacheControlMaxAge,
87
+ });
88
+ return { url: result.url, pathname: result.pathname };
89
+ },
90
+
91
+ async exists(key) {
92
+ try {
93
+ const result = await options.sdk.head(this.urlFor(key));
94
+ return Boolean(result?.url);
95
+ } catch {
96
+ return false;
97
+ }
98
+ },
99
+
100
+ async delete(keyOrUrl) {
101
+ // del() accepts a full blob URL or pathname; pass through unchanged.
102
+ await options.sdk.del(keyOrUrl);
103
+ },
104
+
105
+ async list(prefix) {
106
+ const result = await options.sdk.list(prefix ? { prefix } : {});
107
+ return { keys: result.blobs.map((blob) => blob.pathname) };
108
+ },
109
+
110
+ async listDetailed(prefix) {
111
+ const result = await options.sdk.list(prefix ? { prefix } : {});
112
+ return {
113
+ blobs: result.blobs.map((blob) => ({
114
+ pathname: blob.pathname,
115
+ url: blob.url,
116
+ uploadedAt: blob.uploadedAt,
117
+ })),
118
+ };
119
+ },
120
+ };
121
+ }
122
+
123
+ // @vercel/blob's put() accepts string | Buffer | Blob | ReadableStream etc. Buffer is a
124
+ // Uint8Array subclass, so both port input shapes pass straight through.
125
+ function toUploadBody(data: Uint8Array | Buffer): Buffer {
126
+ return Buffer.isBuffer(data) ? data : Buffer.from(data);
127
+ }
@@ -0,0 +1,109 @@
1
+ // Platform-runtime bundle composition.
2
+ //
3
+ // Resolves the active bundle from the environment and returns its descriptor and port table. Each
4
+ // port slot is filled by one binder, keyed by the bundle's declared capability for that slot. A
5
+ // slot without a binder, or whose binder declines the capability, stays null: callers treat an
6
+ // unbound port as "use the deployment's direct path", never as a silent substitute.
7
+ //
8
+ // The package supplies a default binder for the blob slot. The HTTP, scheduler, data, migrations,
9
+ // analytics and transactional slots carry deployment values (route tables, jobs, the database
10
+ // client factory, migration catalogue, analytics provider, delivery runtime), so the deployment
11
+ // supplies them; `createDataBindings` builds the data binder.
12
+
13
+ import type {
14
+ AnalyticsPort,
15
+ BlobStoragePort,
16
+ DataGatewayPort,
17
+ HttpRuntimePort,
18
+ MigrationRunnerPort,
19
+ SchedulerPort,
20
+ TransactionalRuntimePort,
21
+ } from "@openlup/core/platform-runtime";
22
+
23
+ import { bindBlobPort, type SupabaseStorageClient } from "../blob/index.js";
24
+ import { createServiceClient, readSupabaseDataGatewayEnv } from "../data/index.js";
25
+ import {
26
+ getBundleDescriptor,
27
+ resolveBlobCapability,
28
+ resolveBundleId,
29
+ type PlatformBundleDescriptor,
30
+ type PlatformBundleId,
31
+ } from "../kernel/index.js";
32
+
33
+ type Env = Record<string, string | undefined>;
34
+
35
+ export interface PlatformBundlePorts {
36
+ http: HttpRuntimePort | null;
37
+ scheduler: SchedulerPort | null;
38
+ blob: BlobStoragePort | null;
39
+ data: DataGatewayPort | null;
40
+ migrations: MigrationRunnerPort | null;
41
+ analytics: AnalyticsPort | null;
42
+ transactional: TransactionalRuntimePort | null;
43
+ }
44
+
45
+ export interface PlatformBundle {
46
+ id: PlatformBundleId;
47
+ descriptor: PlatformBundleDescriptor;
48
+ ports: PlatformBundlePorts;
49
+ }
50
+
51
+ /**
52
+ * One binder per port slot. Each receives the slot's capability and the environment; the blob
53
+ * binder receives the selected blob capability (`PLATFORM_BLOB`, else the bundle's own), and the
54
+ * transactional binder also receives the bundle's data capability.
55
+ */
56
+ export interface PlatformBundleBinders {
57
+ /**
58
+ * The supabase-js `createClient` used by the default Supabase Storage blob binder. Declared
59
+ * structurally here so the composition entry's declarations reference no data-module type.
60
+ */
61
+ createSupabaseClient?: (
62
+ url: string,
63
+ key: string,
64
+ options?: { auth?: { persistSession?: boolean; autoRefreshToken?: boolean } },
65
+ ) => unknown;
66
+ http?: (capability: string, env: Env) => HttpRuntimePort | null;
67
+ scheduler?: (capability: string, env: Env) => SchedulerPort | null;
68
+ /** Defaults to `bindBlobPort`. */
69
+ blob?: (capability: string, env: Env) => BlobStoragePort | null;
70
+ /** For example `createDataBindings(...).bindDataPort`. */
71
+ data?: (capability: string, env: Env) => DataGatewayPort | null;
72
+ migrations?: (capability: string, env: Env) => MigrationRunnerPort | null;
73
+ analytics?: (capability: string, env: Env) => AnalyticsPort | null;
74
+ transactional?: (capability: string, dataCapability: string, env: Env) => TransactionalRuntimePort | null;
75
+ }
76
+
77
+ export type ComposeBundle = (env: Env) => PlatformBundle;
78
+
79
+ /** Build the deployment's composition root from its port binders. */
80
+ export function createBundleComposer(binders: PlatformBundleBinders = {}): ComposeBundle {
81
+ const bindBlob = binders.blob ?? ((capability: string, env: Env) => {
82
+ const settings = capability === "supabase" ? readSupabaseDataGatewayEnv({
83
+ ...env,
84
+ SUPABASE_URL: env.SUPABASE_URL?.trim() || env.VITE_SUPABASE_URL?.trim(),
85
+ }) : null;
86
+ const createSupabaseClient = binders.createSupabaseClient;
87
+ return bindBlobPort(capability, env, {
88
+ supabaseStorageClient: settings && createSupabaseClient
89
+ ? () => createServiceClient(settings, createSupabaseClient) as SupabaseStorageClient
90
+ : undefined,
91
+ });
92
+ });
93
+ return function composeBundle(env: Env): PlatformBundle {
94
+ const id = resolveBundleId(env);
95
+ const descriptor = getBundleDescriptor(id);
96
+ const { capabilities } = descriptor;
97
+ const ports: PlatformBundlePorts = {
98
+ http: binders.http?.(capabilities.http, env) ?? null,
99
+ scheduler: null,
100
+ blob: bindBlob(resolveBlobCapability(descriptor, env), env),
101
+ data: binders.data?.(capabilities.data, env) ?? null,
102
+ migrations: binders.migrations?.(capabilities.migrations, env) ?? null,
103
+ analytics: binders.analytics?.(capabilities.analytics, env) ?? null,
104
+ transactional: binders.transactional?.(capabilities.transactional, capabilities.data, env) ?? null,
105
+ };
106
+ ports.scheduler = binders.scheduler?.(capabilities.scheduler, env) ?? null;
107
+ return { id, descriptor, ports };
108
+ };
109
+ }
@@ -0,0 +1,30 @@
1
+ // HTTP slot binding for the Node runtime.
2
+ //
3
+ // Only a bundle whose `http` capability is "node" receives a Node runtime; a hosted bundle keeps
4
+ // `http: null` and serves its route table through its own function entrypoint. The scheduled-route
5
+ // resolver mounts exactly the jobs the deployment declared, so an undeclared `/api/cron/<id>`
6
+ // path stays a 404 and a declared job without an entrypoint fails visibly when called.
7
+
8
+ import type { HttpRuntimePort } from "@openlup/core/platform-runtime";
9
+
10
+ import type { EntrypointLoader } from "../http/index.js";
11
+ import { createNodeHttpRuntime, type NodeHttpRuntimeOptions } from "../http/index.js";
12
+ import type { ScheduledJobDeclaration } from "../scheduler/index.js";
13
+
14
+ /** Mount only the scheduled routes declared by the job registry. */
15
+ export function createScheduledEntrypointResolver(
16
+ jobs: readonly ScheduledJobDeclaration[],
17
+ load: (jobId: string) => ReturnType<EntrypointLoader>,
18
+ ): (jobId: string) => EntrypointLoader | null {
19
+ const declared = new Set(jobs.map((job) => job.jobId));
20
+ return (jobId) => declared.has(jobId) ? () => load(jobId) : null;
21
+ }
22
+
23
+ /** Build the http port for a bundle capability ("node" => Node runtime; else null). */
24
+ export function bindNodeHttpPort(
25
+ capability: string,
26
+ options: () => NodeHttpRuntimeOptions,
27
+ ): HttpRuntimePort | null {
28
+ if (capability !== "node") return null;
29
+ return createNodeHttpRuntime(options());
30
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ createBundleComposer,
3
+ type ComposeBundle,
4
+ type PlatformBundle,
5
+ type PlatformBundleBinders,
6
+ type PlatformBundlePorts,
7
+ } from "./composeBundle.js";
8
+ export { bindNodeHttpPort, createScheduledEntrypointResolver } from "./httpBinding.js";
9
+ export {
10
+ createNodeHost,
11
+ type NodeHost,
12
+ type NodeHostAdmission,
13
+ type NodeHostDependencies,
14
+ type NodeSchedulerProfile,
15
+ type ServeNodeOptions,
16
+ type ServeNodeResult,
17
+ } from "./nodeHost.js";