@rebasepro/server 0.22.0 → 0.24.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.
- package/README.md +1 -1
- package/bin/rebase-server.js +4 -2
- package/dist/{GCSStorageController-CLIJXwGS.js → GCSStorageController-BSiP1c-f.js} +57 -29
- package/dist/GCSStorageController-BSiP1c-f.js.map +1 -0
- package/dist/{S3StorageController-Dcuf8lMA.js → S3StorageController-CAwFRgjV.js} +19 -7
- package/dist/S3StorageController-CAwFRgjV.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +127 -1
- package/dist/api/errors.d.ts +9 -0
- package/dist/api/live-schema-routes.d.ts +52 -8
- package/dist/api/logs-routes.d.ts +39 -1
- package/dist/api/openapi-generator.d.ts +17 -0
- package/dist/api/rest/api-generator.d.ts +158 -42
- package/dist/api/rest/auth-collection-writes.d.ts +85 -0
- package/dist/api/rest/field-access-query.d.ts +6 -2
- package/dist/api/rest/idempotency.d.ts +7 -1
- package/dist/api/rest/nested-write-access.d.ts +46 -0
- package/dist/api/rest/write-validation.d.ts +34 -2
- package/dist/api/types.d.ts +17 -1
- package/dist/{ast-schema-editor-CslO8Oje.js → ast-schema-editor-CWqS_sLJ.js} +411 -13
- package/dist/ast-schema-editor-CWqS_sLJ.js.map +1 -0
- package/dist/auth/access.d.ts +105 -0
- package/dist/auth/adapter-middleware.d.ts +2 -1
- package/dist/auth/address-ownership.d.ts +68 -0
- package/dist/auth/admin-roles-route.d.ts +4 -2
- package/dist/auth/admin-roles.d.ts +17 -20
- package/dist/auth/admin-user-ops.d.ts +35 -2
- package/dist/auth/admin-users-route.d.ts +1 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +56 -55
- package/dist/auth/api-keys/api-key-routes.d.ts +41 -11
- package/dist/auth/api-keys/api-key-store.d.ts +31 -8
- package/dist/auth/api-keys/api-key-types.d.ts +14 -16
- package/dist/auth/api-keys/http-operation.d.ts +19 -0
- package/dist/auth/api-keys/index.d.ts +11 -11
- package/dist/auth/api-keys/key-grant.d.ts +41 -0
- package/dist/auth/api-keys/legacy-permissions.d.ts +33 -0
- package/dist/auth/auth-hooks.d.ts +50 -7
- package/dist/auth/builtin-auth-adapter.d.ts +8 -0
- package/dist/auth/captcha.d.ts +5 -0
- package/dist/auth/cookie-utils.d.ts +7 -0
- package/dist/auth/deliverable-address.d.ts +6 -0
- package/dist/auth/email-change-routes.d.ts +41 -0
- package/dist/auth/expired-token-sweep.d.ts +67 -0
- package/dist/auth/impersonation.d.ts +110 -0
- package/dist/auth/index.d.ts +4 -2
- package/dist/auth/interfaces.d.ts +146 -65
- package/dist/auth/jwt.d.ts +66 -3
- package/dist/auth/magic-link-routes.d.ts +2 -6
- package/dist/auth/mfa-routes.d.ts +2 -9
- package/dist/auth/middleware.d.ts +17 -5
- package/dist/auth/oauth-signin-policy.d.ts +25 -8
- package/dist/auth/otp-routes.d.ts +2 -6
- package/dist/auth/passwordless-signup.d.ts +27 -0
- package/dist/auth/platform-token.d.ts +122 -0
- package/dist/auth/rate-limiter.d.ts +72 -1
- package/dist/auth/routes.d.ts +45 -0
- package/dist/auth/scope-routes.d.ts +22 -0
- package/dist/auth/session-routes.d.ts +18 -6
- package/dist/auth/token-revocation.d.ts +53 -1
- package/dist/auth/verify-credential.d.ts +28 -0
- package/dist/{auth-CCDpk2rn.js → auth-DMLngxn_.js} +2712 -711
- package/dist/auth-DMLngxn_.js.map +1 -0
- package/dist/backend-DTAOsLQc.js +30 -0
- package/dist/backend-DTAOsLQc.js.map +1 -0
- package/dist/backup/backup-common.d.ts +29 -0
- package/dist/backup/backup-routes.d.ts +24 -4
- package/dist/backup/backup-schedule.d.ts +33 -0
- package/dist/backup/backup-storage.d.ts +14 -0
- package/dist/backup/index.d.ts +2 -0
- package/dist/backup-CN0s50D2.js +444 -0
- package/dist/backup-CN0s50D2.js.map +1 -0
- package/dist/boot/bundle.d.ts +19 -0
- package/dist/boot/driver.d.ts +10 -0
- package/dist/boot/env.d.ts +51 -6
- package/dist/boot/fetch-bundle.d.ts +18 -1
- package/dist/boot/rls-audit-option.d.ts +26 -0
- package/dist/boot/security-headers.d.ts +26 -0
- package/dist/boot/sources.d.ts +1 -0
- package/dist/boot/static-routing.d.ts +56 -0
- package/dist/collection_patch-BRu-BvDv.js +472 -0
- package/dist/collection_patch-BRu-BvDv.js.map +1 -0
- package/dist/{contract-routes-eLxV0le1.js → contract-routes-fz8i4pxs.js} +17 -4
- package/dist/contract-routes-fz8i4pxs.js.map +1 -0
- package/dist/cron/cron-routes.d.ts +7 -2
- package/dist/cron/cron-scheduler.d.ts +146 -21
- package/dist/cron/cron-store.d.ts +76 -8
- package/dist/cron/index.d.ts +1 -1
- package/dist/{cron-loader-CQjvjpEw.js → cron-loader-CwaANlOG.js} +4 -4
- package/dist/cron-loader-CwaANlOG.js.map +1 -0
- package/dist/cron-routes-Bc-SB0Se.js +96 -0
- package/dist/cron-routes-Bc-SB0Se.js.map +1 -0
- package/dist/{cron-scheduler-COPQxlEq.js → cron-scheduler-CYQgco86.js} +427 -83
- package/dist/cron-scheduler-CYQgco86.js.map +1 -0
- package/dist/{cron-store-BYGZFNWk.js → cron-store-D2Q9-Aco.js} +139 -23
- package/dist/cron-store-D2Q9-Aco.js.map +1 -0
- package/dist/{ddl-bootstrap-CfNvxMuK.js → ddl-bootstrap-BaqMSa4Y.js} +3 -26
- package/dist/ddl-bootstrap-BaqMSa4Y.js.map +1 -0
- package/dist/email/index.d.ts +2 -2
- package/dist/email/link-base.d.ts +5 -4
- package/dist/email/smtp-email-service.d.ts +13 -1
- package/dist/email/templates.d.ts +31 -0
- package/dist/email/types.d.ts +29 -2
- package/dist/env.d.ts +25 -7
- package/dist/{errors-DWsX4yTd.js → errors-D6_y86c5.js} +102 -8
- package/dist/errors-D6_y86c5.js.map +1 -0
- package/dist/{function-loader-xnbDAPfa.js → function-loader-D7o5Epjj.js} +2 -2
- package/dist/{function-loader-xnbDAPfa.js.map → function-loader-D7o5Epjj.js.map} +1 -1
- package/dist/{function-routes-Chet4-lB.js → function-routes-CaNG4waN.js} +24 -12
- package/dist/function-routes-CaNG4waN.js.map +1 -0
- package/dist/functions/context.d.ts +17 -6
- package/dist/functions/guards.d.ts +22 -5
- package/dist/functions/index.d.ts +2 -2
- package/dist/functions/index.js +90 -36
- package/dist/functions/index.js.map +1 -1
- package/dist/{history-recorder-BQmB0P_j.js → history-recorder-Nr8zLvoU.js} +9 -7
- package/dist/history-recorder-Nr8zLvoU.js.map +1 -0
- package/dist/{history-store-CetkrBBD.js → history-store-rcAm_xFR.js} +2 -2
- package/dist/{history-store-CetkrBBD.js.map → history-store-rcAm_xFR.js.map} +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.es.js +5733 -1551
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +5 -2
- package/dist/init/health.d.ts +17 -2
- package/dist/init/shutdown.d.ts +18 -3
- package/dist/init.d.ts +54 -0
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/job-queue.d.ts +23 -2
- package/dist/jobs/job-store.d.ts +37 -5
- package/dist/jobs/types.d.ts +8 -6
- package/dist/{jobs-Bjr8DZAi.js → jobs-DqYNfquG.js} +306 -167
- package/dist/jobs-DqYNfquG.js.map +1 -0
- package/dist/{jwt-C4OW-DNq.js → jwt-R6bSPMjk.js} +114 -38
- package/dist/{jwt-C4OW-DNq.js.map → jwt-R6bSPMjk.js.map} +1 -1
- package/dist/{keys-Qfc4XieN.js → keys-GAVZqbqx.js} +18 -17
- package/dist/{keys-Qfc4XieN.js.map → keys-GAVZqbqx.js.map} +1 -1
- package/dist/{logger-DO2PZc4i.js → logger-D-S-hO5e.js} +26 -3
- package/dist/logger-D-S-hO5e.js.map +1 -0
- package/dist/{logs-routes-3EEzPjhl.js → logs-routes-DAdv37GI.js} +54 -11
- package/dist/logs-routes-DAdv37GI.js.map +1 -0
- package/dist/mcp/consent-page.d.ts +1 -1
- package/dist/mcp/mcp-routes.d.ts +45 -2
- package/dist/mcp/mcp-tools.d.ts +22 -10
- package/dist/mcp/oauth-metadata.d.ts +21 -16
- package/dist/mcp/oauth-routes.d.ts +34 -1
- package/dist/mcp/oauth-store.d.ts +29 -13
- package/dist/metrics/history-recorder.d.ts +1 -1
- package/dist/{openapi-generator-D8uFz-LW.js → openapi-generator-DAq_XVDu.js} +135 -22
- package/dist/openapi-generator-DAq_XVDu.js.map +1 -0
- package/dist/{proxy-Czngl3p9.js → proxy-qRlqeUmO.js} +2 -2
- package/dist/{proxy-Czngl3p9.js.map → proxy-qRlqeUmO.js.map} +1 -1
- package/dist/{query-parser-BleZmY18.js → query-parser-BgiKJKvc.js} +41 -82
- package/dist/query-parser-BgiKJKvc.js.map +1 -0
- package/dist/{request-timeout-C_4C2BeR.js → request-timeout-DgH7j8qO.js} +3 -3
- package/dist/{request-timeout-C_4C2BeR.js.map → request-timeout-DgH7j8qO.js.map} +1 -1
- package/dist/rls-audit/index.d.ts +4 -0
- package/dist/schema-edit/apply-schema-change.d.ts +63 -3
- package/dist/schema-edit/project-root.d.ts +3 -2
- package/dist/schema-edit/remote-source.d.ts +9 -4
- package/dist/{schema-editor-routes-DdLihzp0.js → schema-editor-routes-oIyuWl3L.js} +12 -7
- package/dist/schema-editor-routes-oIyuWl3L.js.map +1 -0
- package/dist/serve-spa.d.ts +58 -0
- package/dist/services/routed-realtime-service.d.ts +11 -0
- package/dist/soft-delete-params-BWPilMPF.js +59 -0
- package/dist/soft-delete-params-BWPilMPF.js.map +1 -0
- package/dist/{src-Br6ARbs6.js → src-CatHFUym.js} +439 -45
- package/dist/src-CatHFUym.js.map +1 -0
- package/dist/{src-1vL-I1Po.js → src-I3aG1PcY.js} +371 -81
- package/dist/src-I3aG1PcY.js.map +1 -0
- package/dist/storage/GCSStorageController.d.ts +13 -1
- package/dist/storage/LocalStorageController.d.ts +2 -0
- package/dist/storage/S3StorageController.d.ts +2 -0
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/keys.d.ts +12 -0
- package/dist/storage/property-limits.d.ts +41 -6
- package/dist/storage/rendition-cache.d.ts +11 -1
- package/dist/storage/request-keys.d.ts +82 -0
- package/dist/storage/requested-object.d.ts +74 -0
- package/dist/storage/routes.d.ts +36 -18
- package/dist/storage/tus-handler.d.ts +30 -5
- package/dist/storage/types.d.ts +36 -1
- package/dist/types-BfKcm9do.js.map +1 -1
- package/dist/utils/logger.d.ts +12 -0
- package/package.json +9 -9
- package/dist/GCSStorageController-CLIJXwGS.js.map +0 -1
- package/dist/S3StorageController-Dcuf8lMA.js.map +0 -1
- package/dist/admin-roles-vYdp_Pil.js +0 -36
- package/dist/admin-roles-vYdp_Pil.js.map +0 -1
- package/dist/admin_block-DxKLmdiv.js +0 -206
- package/dist/admin_block-DxKLmdiv.js.map +0 -1
- package/dist/ast-schema-editor-CslO8Oje.js.map +0 -1
- package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -65
- package/dist/auth-CCDpk2rn.js.map +0 -1
- package/dist/backup-DzI9jLwc.js +0 -192
- package/dist/backup-DzI9jLwc.js.map +0 -1
- package/dist/contract-routes-eLxV0le1.js.map +0 -1
- package/dist/cron-loader-CQjvjpEw.js.map +0 -1
- package/dist/cron-routes-B7CRGfiq.js +0 -72
- package/dist/cron-routes-B7CRGfiq.js.map +0 -1
- package/dist/cron-scheduler-COPQxlEq.js.map +0 -1
- package/dist/cron-store-BYGZFNWk.js.map +0 -1
- package/dist/ddl-bootstrap-CfNvxMuK.js.map +0 -1
- package/dist/errors-DWsX4yTd.js.map +0 -1
- package/dist/function-routes-Chet4-lB.js.map +0 -1
- package/dist/history-recorder-BQmB0P_j.js.map +0 -1
- package/dist/jobs-Bjr8DZAi.js.map +0 -1
- package/dist/logger-DO2PZc4i.js.map +0 -1
- package/dist/logs-routes-3EEzPjhl.js.map +0 -1
- package/dist/openapi-generator-D8uFz-LW.js.map +0 -1
- package/dist/query-parser-BleZmY18.js.map +0 -1
- package/dist/schema-editor-routes-DdLihzp0.js.map +0 -1
- package/dist/src-1vL-I1Po.js.map +0 -1
- package/dist/src-Br6ARbs6.js.map +0 -1
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ This is the central orchestrator for any Rebase backend. It provides the framewo
|
|
|
33
33
|
|
|
34
34
|
Also exports `HonoEnv`, the auth, email, storage, history and cron modules, custom functions, logging utilities, and the driver registry. The abstract driver interfaces (`DatabaseAdapter`, `AuthAdapter`, `DataDriver`, …) are not re-exported: import them from `@rebasepro/types`.
|
|
35
35
|
|
|
36
|
-
`@rebasepro/server/functions` is the portable surface a function file imports — `defineFunction`, the `requireAuth`/`requireAdmin` guards, `getUser`, `rebase` and the rest. The package also ships the `rebase-server` binary, which runs a built project bundle (`rebase-server ./dist-bundle`); it is what the `rebasepro/server` image executes.
|
|
36
|
+
`@rebasepro/server/functions` is the portable surface a function file imports — `defineFunction`, the `requireAuth`/`requireAdmin`/`requireScope` guards, `getUser`, `getScopes`, `hasScope`, `rebase` and the rest. The package also ships the `rebase-server` binary, which runs a built project bundle (`rebase-server ./dist-bundle`); it is what the `rebasepro/server` image executes.
|
|
37
37
|
|
|
38
38
|
## Quick Start
|
|
39
39
|
|
package/bin/rebase-server.js
CHANGED
|
@@ -31,6 +31,9 @@ Key environment variables:
|
|
|
31
31
|
window, requires CORS_ORIGINS, refuses local file
|
|
32
32
|
storage and turns the OpenAPI docs off
|
|
33
33
|
DATABASE_URL Connection string for the default database (required)
|
|
34
|
+
ALLOW_LOCALHOST_IN_PRODUCTION "true" when the database (or any other
|
|
35
|
+
service a variable names) runs on this same machine.
|
|
36
|
+
Production refuses localhost addresses without it
|
|
34
37
|
JWT_SECRET Signing secret, >=32 chars (required in production)
|
|
35
38
|
REBASE_SERVICE_KEY Server-to-server credential that bypasses row-level
|
|
36
39
|
security. Treat it like a database superuser password
|
|
@@ -46,8 +49,7 @@ Key environment variables:
|
|
|
46
49
|
tables and columns at boot, INCLUDING your collections',
|
|
47
50
|
additively, and applies their row-level security. It
|
|
48
51
|
never alters, drops or narrows: those go through
|
|
49
|
-
'rebase db push' from a checkout
|
|
50
|
-
junction-table RLS for many-to-many relations
|
|
52
|
+
'rebase db push' from a checkout
|
|
51
53
|
|
|
52
54
|
Additional databases and buckets are configured by suffixing the variable with
|
|
53
55
|
the source key, e.g. DATABASE_URL__ANALYTICS or S3_BUCKET__MEDIA.
|
|
@@ -3,7 +3,8 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import {
|
|
6
|
+
import { t as ApiError } from "./errors-D6_y86c5.js";
|
|
7
|
+
import { c as listingPrefix, s as folderKey } from "./keys-GAVZqbqx.js";
|
|
7
8
|
import "./types-BfKcm9do.js";
|
|
8
9
|
//#region src/storage/GCSStorageController.ts
|
|
9
10
|
/**
|
|
@@ -72,7 +73,16 @@ var GCSStorageController = class {
|
|
|
72
73
|
storageUrl: `gs://${usedBucket}/${key}`
|
|
73
74
|
};
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Read the object's metadata and nothing else — no signing.
|
|
78
|
+
*
|
|
79
|
+
* Signing needs a private key. With `keyFilename` or `credentials` the
|
|
80
|
+
* client has one; on Cloud Run, GKE or GCE it has only the metadata
|
|
81
|
+
* server's token, and falls back to the IAM Credentials `signBlob` API,
|
|
82
|
+
* which the runtime account may call on itself only when it holds
|
|
83
|
+
* `iam.serviceAccounts.signBlob` there. That is not a default grant.
|
|
84
|
+
*/
|
|
85
|
+
async getMetadata(key, bucket) {
|
|
76
86
|
let resolvedPath = key;
|
|
77
87
|
let resolvedBucket = this.getBucket(bucket);
|
|
78
88
|
const match = key.match(/^(gs|s3):\/\//);
|
|
@@ -86,32 +96,37 @@ var GCSStorageController = class {
|
|
|
86
96
|
}
|
|
87
97
|
}
|
|
88
98
|
try {
|
|
89
|
-
const
|
|
90
|
-
const [fileMetadata] = await gcsFile.getMetadata();
|
|
91
|
-
const expiresIn = this.config.signedUrlExpiration ?? 3600;
|
|
92
|
-
const [url] = await gcsFile.getSignedUrl({
|
|
93
|
-
action: "read",
|
|
94
|
-
expires: Date.now() + expiresIn * 1e3
|
|
95
|
-
});
|
|
99
|
+
const [fileMetadata] = await (await this.getClient()).bucket(resolvedBucket).file(resolvedPath).getMetadata();
|
|
96
100
|
return {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
contentType: fileMetadata.contentType || "application/octet-stream",
|
|
104
|
-
customMetadata: fileMetadata.metadata || {}
|
|
105
|
-
}
|
|
101
|
+
bucket: resolvedBucket,
|
|
102
|
+
fullPath: resolvedPath,
|
|
103
|
+
name: resolvedPath.split("/").pop() || resolvedPath,
|
|
104
|
+
size: Number(fileMetadata.size) || 0,
|
|
105
|
+
contentType: fileMetadata.contentType || "application/octet-stream",
|
|
106
|
+
customMetadata: fileMetadata.metadata || {}
|
|
106
107
|
};
|
|
107
108
|
} catch (error) {
|
|
108
|
-
if (isNotFoundError(error)) return
|
|
109
|
-
url: null,
|
|
110
|
-
fileNotFound: true
|
|
111
|
-
};
|
|
109
|
+
if (isNotFoundError(error)) return null;
|
|
112
110
|
throw error;
|
|
113
111
|
}
|
|
114
112
|
}
|
|
113
|
+
async getSignedUrl(key, bucket) {
|
|
114
|
+
const metadata = await this.getMetadata(key, bucket);
|
|
115
|
+
if (!metadata) return {
|
|
116
|
+
url: null,
|
|
117
|
+
fileNotFound: true
|
|
118
|
+
};
|
|
119
|
+
const client = await this.getClient();
|
|
120
|
+
const expiresIn = this.config.signedUrlExpiration ?? 3600;
|
|
121
|
+
const [url] = await client.bucket(metadata.bucket).file(metadata.fullPath).getSignedUrl({
|
|
122
|
+
action: "read",
|
|
123
|
+
expires: Date.now() + expiresIn * 1e3
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
url,
|
|
127
|
+
metadata
|
|
128
|
+
};
|
|
129
|
+
}
|
|
115
130
|
async getObject(key, bucket) {
|
|
116
131
|
let resolvedPath = key;
|
|
117
132
|
let resolvedBucket = this.getBucket(bucket);
|
|
@@ -132,7 +147,11 @@ var GCSStorageController = class {
|
|
|
132
147
|
const contentType = fileMetadata.contentType || "application/octet-stream";
|
|
133
148
|
const fileName = resolvedPath.split("/").pop() || resolvedPath;
|
|
134
149
|
const blob = new Blob([new Uint8Array(contents)], { type: contentType });
|
|
135
|
-
|
|
150
|
+
const updated = typeof fileMetadata.updated === "string" ? Date.parse(fileMetadata.updated) : NaN;
|
|
151
|
+
return new File([blob], fileName, {
|
|
152
|
+
type: contentType,
|
|
153
|
+
lastModified: Number.isNaN(updated) ? void 0 : updated
|
|
154
|
+
});
|
|
136
155
|
} catch (error) {
|
|
137
156
|
if (isNotFoundError(error)) return null;
|
|
138
157
|
throw error;
|
|
@@ -155,15 +174,17 @@ var GCSStorageController = class {
|
|
|
155
174
|
}
|
|
156
175
|
async listObjects(prefix, options) {
|
|
157
176
|
const resolvedBucket = this.getBucket(options?.bucket);
|
|
158
|
-
const
|
|
159
|
-
|
|
177
|
+
const gcsBucket = (await this.getClient()).bucket(resolvedBucket);
|
|
178
|
+
const folderPrefix = listingPrefix(prefix);
|
|
179
|
+
const [listed, , filesApiResponse] = await gcsBucket.getFiles({
|
|
180
|
+
prefix: folderPrefix,
|
|
160
181
|
delimiter: "/",
|
|
161
182
|
maxResults: options?.maxResults ?? 1e3,
|
|
162
183
|
pageToken: options?.pageToken,
|
|
163
184
|
autoPaginate: false
|
|
164
185
|
});
|
|
165
186
|
return {
|
|
166
|
-
items:
|
|
187
|
+
items: listed.filter((file) => file.name !== folderPrefix).map((file) => ({
|
|
167
188
|
bucket: resolvedBucket,
|
|
168
189
|
fullPath: file.name,
|
|
169
190
|
name: file.name.split("/").pop() || "",
|
|
@@ -185,12 +206,19 @@ var GCSStorageController = class {
|
|
|
185
206
|
nextPageToken: filesApiResponse?.nextPageToken
|
|
186
207
|
};
|
|
187
208
|
}
|
|
209
|
+
/** See {@link StorageController.maxFileSize}. */
|
|
210
|
+
maxFileSize() {
|
|
211
|
+
return this.config.maxFileSize ?? 52428800;
|
|
212
|
+
}
|
|
188
213
|
/**
|
|
189
214
|
* Validate file before upload
|
|
190
215
|
*/
|
|
191
216
|
validateFile(file) {
|
|
192
|
-
const maxSize = this.
|
|
193
|
-
if (file.size > maxSize) throw new
|
|
217
|
+
const maxSize = this.maxFileSize();
|
|
218
|
+
if (file.size > maxSize) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `File size ${file.size} exceeds this storage source's maximum of ${maxSize} bytes.`, {
|
|
219
|
+
size: file.size,
|
|
220
|
+
maxFileSize: maxSize
|
|
221
|
+
});
|
|
194
222
|
if (this.config.allowedMimeTypes && this.config.allowedMimeTypes.length > 0) {
|
|
195
223
|
if (!this.config.allowedMimeTypes.includes(file.type)) throw new Error(`File type ${file.type} is not allowed. Allowed types: ${this.config.allowedMimeTypes.join(", ")}`);
|
|
196
224
|
}
|
|
@@ -225,4 +253,4 @@ function isNotFoundError(error) {
|
|
|
225
253
|
//#endregion
|
|
226
254
|
export { GCSStorageController_exports as n, GCSStorageController as t };
|
|
227
255
|
|
|
228
|
-
//# sourceMappingURL=GCSStorageController-
|
|
256
|
+
//# sourceMappingURL=GCSStorageController-BSiP1c-f.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"GCSStorageController-BSiP1c-f.js","names":[],"sources":["../src/storage/GCSStorageController.ts"],"sourcesContent":["/**\n * Google Cloud Storage controller (works with GCS and Firebase Storage)\n *\n * The `@google-cloud/storage` package is loaded lazily so it remains an\n * optional peer dependency — users who don't need GCS never pay for the\n * import cost or need it installed.\n */\n\nimport {\n StorageController,\n GCSStorageConfig,\n DEFAULT_MAX_FILE_SIZE\n} from \"./types\";\nimport { folderKey, listingPrefix } from \"./keys\";\nimport { ApiError } from \"../api/errors\";\nimport {\n UploadFileProps,\n UploadFileResult,\n DownloadConfig,\n DownloadMetadata,\n StorageListResult,\n StorageReference\n} from \"@rebasepro/types\";\n\n// ---------------------------------------------------------------------------\n// Lazy import of @google-cloud/storage\n// ---------------------------------------------------------------------------\n\nlet gcsModule: typeof import(\"@google-cloud/storage\") | null = null;\n\nasync function loadGCS(): Promise<typeof import(\"@google-cloud/storage\")> {\n if (gcsModule) return gcsModule;\n try {\n gcsModule = await import(\"@google-cloud/storage\");\n return gcsModule;\n } catch {\n throw new Error(\n \"Google Cloud Storage is required for the GCS storage controller. \" +\n \"Install it with: pnpm add @google-cloud/storage\"\n );\n }\n}\n\n// ---------------------------------------------------------------------------\n// GCSStorageController\n// ---------------------------------------------------------------------------\n\n/**\n * Google Cloud Storage implementation of `StorageController`.\n *\n * Works with standard GCS buckets **and** Firebase Storage buckets\n * (e.g. `\"my-project.appspot.com\"`).\n */\nexport class GCSStorageController implements StorageController {\n private config: GCSStorageConfig;\n private client: InstanceType<typeof import(\"@google-cloud/storage\").Storage> | null = null;\n\n constructor(config: GCSStorageConfig) {\n this.config = config;\n }\n\n // ------------------------------------------------------------------\n // Lazy client initialisation\n // ------------------------------------------------------------------\n\n private async getClient(): Promise<InstanceType<typeof import(\"@google-cloud/storage\").Storage>> {\n if (this.client) return this.client;\n const { Storage } = await loadGCS();\n this.client = new Storage({\n projectId: this.config.projectId,\n keyFilename: this.config.keyFilename,\n credentials: this.config.credentials\n });\n return this.client;\n }\n\n // ------------------------------------------------------------------\n // StorageController interface\n // ------------------------------------------------------------------\n\n getType(): \"gcs\" {\n return \"gcs\";\n }\n\n /**\n * The configured bucket, and the logical `default` that maps to it.\n * @see S3StorageController.knownBuckets\n */\n knownBuckets(): string[] {\n return [\"default\", this.config.bucket];\n }\n\n async putObject({\n file,\n key,\n metadata,\n bucket\n }: UploadFileProps): Promise<UploadFileResult> {\n this.validateFile(file);\n\n const usedBucket = this.getBucket(bucket);\n const client = await this.getClient();\n const gcsFile = client.bucket(usedBucket).file(key);\n\n // Convert File to Buffer\n const arrayBuffer = await file.arrayBuffer();\n const buffer = Buffer.from(arrayBuffer);\n\n await gcsFile.save(buffer, {\n contentType: file.type,\n metadata: metadata ? { metadata: this.flattenMetadata(metadata) } : undefined\n });\n\n return {\n key,\n bucket: usedBucket,\n storageUrl: `gs://${usedBucket}/${key}`\n };\n }\n\n /**\n * Read the object's metadata and nothing else — no signing.\n *\n * Signing needs a private key. With `keyFilename` or `credentials` the\n * client has one; on Cloud Run, GKE or GCE it has only the metadata\n * server's token, and falls back to the IAM Credentials `signBlob` API,\n * which the runtime account may call on itself only when it holds\n * `iam.serviceAccounts.signBlob` there. That is not a default grant.\n */\n async getMetadata(key: string, bucket?: string): Promise<DownloadMetadata | null> {\n // Handle gs:// and s3:// URLs\n let resolvedPath = key;\n let resolvedBucket = this.getBucket(bucket);\n\n const match = key.match(/^(gs|s3):\\/\\//);\n if (match) {\n const protocolLength = match[0].length;\n const withoutProtocol = key.substring(protocolLength);\n const firstSlash = withoutProtocol.indexOf(\"/\");\n if (firstSlash > 0) {\n resolvedBucket = withoutProtocol.substring(0, firstSlash);\n resolvedPath = withoutProtocol.substring(firstSlash + 1);\n }\n }\n\n try {\n const client = await this.getClient();\n const [fileMetadata] = await client.bucket(resolvedBucket).file(resolvedPath).getMetadata();\n\n return {\n bucket: resolvedBucket,\n fullPath: resolvedPath,\n name: resolvedPath.split(\"/\").pop() || resolvedPath,\n size: Number(fileMetadata.size) || 0,\n contentType: fileMetadata.contentType || \"application/octet-stream\",\n customMetadata: (fileMetadata.metadata as Record<string, string> | undefined) || {}\n };\n } catch (error: unknown) {\n if (isNotFoundError(error)) {\n return null;\n }\n throw error;\n }\n }\n\n async getSignedUrl(key: string, bucket?: string): Promise<DownloadConfig> {\n // Metadata first, to check existence and populate the response.\n const metadata = await this.getMetadata(key, bucket);\n if (!metadata) {\n return {\n url: null,\n fileNotFound: true\n };\n }\n\n const client = await this.getClient();\n const expiresIn = this.config.signedUrlExpiration ?? 3600;\n const [url] = await client.bucket(metadata.bucket).file(metadata.fullPath).getSignedUrl({\n action: \"read\",\n expires: Date.now() + expiresIn * 1000\n });\n\n return {\n url,\n metadata\n };\n }\n\n async getObject(key: string, bucket?: string): Promise<File | null> {\n // Handle gs:// and s3:// URLs\n let resolvedPath = key;\n let resolvedBucket = this.getBucket(bucket);\n\n const match = key.match(/^(gs|s3):\\/\\//);\n if (match) {\n const protocolLength = match[0].length;\n const withoutProtocol = key.substring(protocolLength);\n const firstSlash = withoutProtocol.indexOf(\"/\");\n if (firstSlash > 0) {\n resolvedBucket = withoutProtocol.substring(0, firstSlash);\n resolvedPath = withoutProtocol.substring(firstSlash + 1);\n }\n }\n\n try {\n const client = await this.getClient();\n const gcsFile = client.bucket(resolvedBucket).file(resolvedPath);\n\n const [contents] = await gcsFile.download();\n\n // Get content type from metadata\n const [fileMetadata] = await gcsFile.getMetadata();\n const contentType = fileMetadata.contentType || \"application/octet-stream\";\n const fileName = resolvedPath.split(\"/\").pop() || resolvedPath;\n\n const blob = new Blob([new Uint8Array(contents)], { type: contentType });\n // The object's own version — see `S3StorageController.getObject`.\n const updated = typeof fileMetadata.updated === \"string\" ? Date.parse(fileMetadata.updated) : NaN;\n return new File([blob], fileName, {\n type: contentType,\n lastModified: Number.isNaN(updated) ? undefined : updated\n });\n } catch (error: unknown) {\n if (isNotFoundError(error)) {\n return null;\n }\n throw error;\n }\n }\n\n async deleteObject(key: string, bucket?: string): Promise<void> {\n // Handle gs:// and s3:// URLs\n let resolvedPath = key;\n let resolvedBucket = this.getBucket(bucket);\n\n const match = key.match(/^(gs|s3):\\/\\//);\n if (match) {\n const protocolLength = match[0].length;\n const withoutProtocol = key.substring(protocolLength);\n const firstSlash = withoutProtocol.indexOf(\"/\");\n if (firstSlash > 0) {\n resolvedBucket = withoutProtocol.substring(0, firstSlash);\n resolvedPath = withoutProtocol.substring(firstSlash + 1);\n }\n }\n\n const client = await this.getClient();\n const gcsFile = client.bucket(resolvedBucket).file(resolvedPath);\n\n await gcsFile.delete({ ignoreNotFound: true });\n }\n\n async listObjects(prefix: string, options?: {\n bucket?: string;\n maxResults?: number;\n pageToken?: string;\n }): Promise<StorageListResult> {\n const resolvedBucket = this.getBucket(options?.bucket);\n const client = await this.getClient();\n const gcsBucket = client.bucket(resolvedBucket);\n\n // Fetch files (objects) matching the prefix\n const folderPrefix = listingPrefix(prefix);\n const [listed, , filesApiResponse] = await gcsBucket.getFiles({\n prefix: folderPrefix,\n delimiter: \"/\",\n maxResults: options?.maxResults ?? 1000,\n pageToken: options?.pageToken,\n autoPaginate: false\n });\n\n // The folder's own marker object is not something in the folder —\n // see `S3StorageController.listObjects`.\n const files = listed.filter(file => file.name !== folderPrefix);\n const items: StorageReference[] = files.map(file => ({\n bucket: resolvedBucket,\n fullPath: file.name,\n name: file.name.split(\"/\").pop() || \"\",\n parent: null as never,\n root: null as never,\n toString: () => `gs://${resolvedBucket}/${file.name}`\n }));\n\n // Common prefixes (folder-like entries) come from the API response\n const apiPrefixes: string[] =\n (filesApiResponse as Record<string, unknown> | undefined)?.prefixes as string[] ?? [];\n\n const prefixes: StorageReference[] = apiPrefixes.map(p => {\n // Without the trailing slash GCS puts on a common prefix, so a\n // `fullPath` from a listing is a key any controller accepts.\n const folder = folderKey(p);\n return {\n bucket: resolvedBucket,\n fullPath: folder,\n name: folder.split(\"/\").pop() || \"\",\n parent: null as never,\n root: null as never,\n toString: () => `gs://${resolvedBucket}/${folder}`\n };\n });\n\n // The next page token is returned on the query object (second element)\n const nextPageToken =\n (filesApiResponse as Record<string, unknown> | undefined)?.nextPageToken as string | undefined;\n\n return {\n items,\n prefixes,\n nextPageToken\n };\n }\n\n // ------------------------------------------------------------------\n // Private helpers\n // ------------------------------------------------------------------\n\n /** See {@link StorageController.maxFileSize}. */\n maxFileSize(): number {\n return this.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;\n }\n\n /**\n * Validate file before upload\n */\n private validateFile(file: File): void {\n const maxSize = this.maxFileSize();\n if (file.size > maxSize) {\n // An ApiError, so the refusal reaches the caller as what it is. A\n // plain Error was a 500 on `/upload` and a 502 on TUS — after the\n // client had sent every byte.\n throw new ApiError(\n 413,\n \"PAYLOAD_TOO_LARGE\",\n `File size ${file.size} exceeds this storage source's maximum of ${maxSize} bytes.`,\n { size: file.size, maxFileSize: maxSize }\n );\n }\n\n if (this.config.allowedMimeTypes && this.config.allowedMimeTypes.length > 0) {\n if (!this.config.allowedMimeTypes.includes(file.type)) {\n throw new Error(`File type ${file.type} is not allowed. Allowed types: ${this.config.allowedMimeTypes.join(\", \")}`);\n }\n }\n }\n\n /**\n * Get the bucket name — either from parameter or config.\n */\n private getBucket(bucket?: string): string {\n // \"default\" is a logical bucket name used by local storage;\n // for GCS it should resolve to the configured bucket.\n if (!bucket || bucket === \"default\") return this.config.bucket;\n return bucket;\n }\n\n /**\n * Flatten nested metadata to string values (GCS custom metadata requirement)\n */\n private flattenMetadata(metadata: Record<string, unknown>): Record<string, string> {\n const flattened: Record<string, string> = {};\n for (const [key, value] of Object.entries(metadata)) {\n if (typeof value === \"string\") {\n flattened[key] = value;\n } else if (value !== undefined && value !== null) {\n flattened[key] = JSON.stringify(value);\n }\n }\n return flattened;\n }\n}\n\n// ---------------------------------------------------------------------------\n// Error helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Check whether an error represents a 404 / Not Found from the GCS client.\n */\nfunction isNotFoundError(error: unknown): boolean {\n if (typeof error !== \"object\" || error === null) return false;\n const err = error as Record<string, unknown>;\n if (err[\"code\"] === 404) return true;\n if (typeof err[\"message\"] === \"string\" && /not found/i.test(err[\"message\"])) return true;\n return false;\n}\n"],"mappings":"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"}
|
|
@@ -3,7 +3,8 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import {
|
|
6
|
+
import { t as ApiError } from "./errors-D6_y86c5.js";
|
|
7
|
+
import { c as listingPrefix, s as folderKey } from "./keys-GAVZqbqx.js";
|
|
7
8
|
import "./types-BfKcm9do.js";
|
|
8
9
|
//#region src/storage/S3StorageController.ts
|
|
9
10
|
var S3StorageController_exports = /* @__PURE__ */ __exportAll({ S3StorageController: () => S3StorageController });
|
|
@@ -66,12 +67,19 @@ var S3StorageController = class {
|
|
|
66
67
|
knownBuckets() {
|
|
67
68
|
return ["default", this.config.bucket];
|
|
68
69
|
}
|
|
70
|
+
/** See {@link StorageController.maxFileSize}. */
|
|
71
|
+
maxFileSize() {
|
|
72
|
+
return this.config.maxFileSize ?? 52428800;
|
|
73
|
+
}
|
|
69
74
|
/**
|
|
70
75
|
* Validate file before upload
|
|
71
76
|
*/
|
|
72
77
|
validateFile(file) {
|
|
73
|
-
const maxSize = this.
|
|
74
|
-
if (file.size > maxSize) throw new
|
|
78
|
+
const maxSize = this.maxFileSize();
|
|
79
|
+
if (file.size > maxSize) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `File size ${file.size} exceeds this storage source's maximum of ${maxSize} bytes.`, {
|
|
80
|
+
size: file.size,
|
|
81
|
+
maxFileSize: maxSize
|
|
82
|
+
});
|
|
75
83
|
if (this.config.allowedMimeTypes && this.config.allowedMimeTypes.length > 0) {
|
|
76
84
|
if (!this.config.allowedMimeTypes.includes(file.type)) throw new Error(`File type ${file.type} is not allowed. Allowed types: ${this.config.allowedMimeTypes.join(", ")}`);
|
|
77
85
|
}
|
|
@@ -188,7 +196,10 @@ var S3StorageController = class {
|
|
|
188
196
|
const contentType = response.ContentType || "application/octet-stream";
|
|
189
197
|
const fileName = resolvedPath.split("/").pop() || resolvedPath;
|
|
190
198
|
const blob = new Blob([buffer], { type: contentType });
|
|
191
|
-
return new File([blob], fileName, {
|
|
199
|
+
return new File([blob], fileName, {
|
|
200
|
+
type: contentType,
|
|
201
|
+
lastModified: response.LastModified?.getTime()
|
|
202
|
+
});
|
|
192
203
|
} catch (error) {
|
|
193
204
|
const s3Error = error;
|
|
194
205
|
if (s3Error.name === "NoSuchKey" || s3Error.$metadata?.httpStatusCode === 404) return null;
|
|
@@ -226,16 +237,17 @@ var S3StorageController = class {
|
|
|
226
237
|
const resolvedBucket = this.getBucket(options?.bucket);
|
|
227
238
|
const s3 = await loadS3();
|
|
228
239
|
const client = await this.getClient();
|
|
240
|
+
const folderPrefix = listingPrefix(prefix);
|
|
229
241
|
const command = new s3.ListObjectsV2Command({
|
|
230
242
|
Bucket: resolvedBucket,
|
|
231
|
-
Prefix:
|
|
243
|
+
Prefix: folderPrefix,
|
|
232
244
|
MaxKeys: options?.maxResults ?? 1e3,
|
|
233
245
|
ContinuationToken: options?.pageToken,
|
|
234
246
|
Delimiter: "/"
|
|
235
247
|
});
|
|
236
248
|
const response = await client.send(command);
|
|
237
249
|
return {
|
|
238
|
-
items: (response.Contents || []).map((obj) => ({
|
|
250
|
+
items: (response.Contents || []).filter((obj) => obj.Key !== folderPrefix).map((obj) => ({
|
|
239
251
|
bucket: resolvedBucket,
|
|
240
252
|
fullPath: obj.Key || "",
|
|
241
253
|
name: (obj.Key || "").split("/").pop() || "",
|
|
@@ -261,4 +273,4 @@ var S3StorageController = class {
|
|
|
261
273
|
//#endregion
|
|
262
274
|
export { S3StorageController_exports as n, S3StorageController as t };
|
|
263
275
|
|
|
264
|
-
//# sourceMappingURL=S3StorageController-
|
|
276
|
+
//# sourceMappingURL=S3StorageController-CAwFRgjV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"S3StorageController-CAwFRgjV.js","names":[],"sources":["../src/storage/S3StorageController.ts"],"sourcesContent":["/**\n * S3-compatible storage controller (works with AWS S3 and MinIO)\n */\n\nimport type { S3Client as S3ClientType, } from \"@aws-sdk/client-s3\";\nimport { DEFAULT_MAX_FILE_SIZE, S3StorageConfig, StorageController } from \"./types\";\nimport { folderKey, listingPrefix } from \"./keys\";\nimport { ApiError } from \"../api/errors\";\nimport {\n DownloadConfig,\n DownloadMetadata,\n StorageListResult,\n StorageReference,\n UploadFileProps,\n UploadFileResult\n} from \"@rebasepro/types\";\n\nlet _s3Module: typeof import(\"@aws-sdk/client-s3\") | undefined;\nlet _presignerModule: typeof import(\"@aws-sdk/s3-request-presigner\") | undefined;\n\nasync function loadS3() {\n if (!_s3Module) {\n try {\n _s3Module = await import(\"@aws-sdk/client-s3\");\n } catch {\n throw new Error(\n \"@aws-sdk/client-s3 is required for S3 storage. \" +\n \"Install it: pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner\"\n );\n }\n }\n return _s3Module;\n}\n\nasync function loadPresigner() {\n if (!_presignerModule) {\n try {\n _presignerModule = await import(\"@aws-sdk/s3-request-presigner\");\n } catch {\n throw new Error(\n \"@aws-sdk/s3-request-presigner is required for S3 storage. \" +\n \"Install it: pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner\"\n );\n }\n }\n return _presignerModule;\n}\n\n/**\n * S3-compatible storage implementation\n * Works with AWS S3 and MinIO (with forcePathStyle option)\n */\nexport class S3StorageController implements StorageController {\n private config: S3StorageConfig;\n private _client: S3ClientType | undefined;\n\n constructor(config: S3StorageConfig) {\n this.config = config;\n }\n\n /**\n * Lazily create and cache the S3 client on first use\n */\n private async getClient(): Promise<S3ClientType> {\n if (!this._client) {\n const s3 = await loadS3();\n this._client = new s3.S3Client({\n region: this.config.region || \"us-east-1\",\n endpoint: this.config.endpoint,\n forcePathStyle: this.config.forcePathStyle ?? !!this.config.endpoint, // Auto-enable for custom endpoints (MinIO)\n credentials: {\n accessKeyId: this.config.accessKeyId,\n secretAccessKey: this.config.secretAccessKey\n }\n });\n }\n return this._client;\n }\n\n getType(): \"s3\" {\n return \"s3\";\n }\n\n /**\n * The configured bucket, and the logical `default` that maps to it.\n *\n * Any other name went straight to S3 as a bucket name, so the request\n * parameter was a way to address *any* bucket the deployment's credentials\n * can reach — and a mistyped one came back as \"file not found\".\n */\n knownBuckets(): string[] {\n return [\"default\", this.config.bucket];\n }\n\n /** See {@link StorageController.maxFileSize}. */\n maxFileSize(): number {\n return this.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;\n }\n\n /**\n * Validate file before upload\n */\n private validateFile(file: File): void {\n const maxSize = this.maxFileSize();\n if (file.size > maxSize) {\n // An ApiError, so the refusal reaches the caller as what it is. A\n // plain Error was a 500 on `/upload` and a 502 on TUS — after the\n // client had sent every byte.\n throw new ApiError(\n 413,\n \"PAYLOAD_TOO_LARGE\",\n `File size ${file.size} exceeds this storage source's maximum of ${maxSize} bytes.`,\n { size: file.size, maxFileSize: maxSize }\n );\n }\n\n if (this.config.allowedMimeTypes && this.config.allowedMimeTypes.length > 0) {\n if (!this.config.allowedMimeTypes.includes(file.type)) {\n throw new Error(`File type ${file.type} is not allowed. Allowed types: ${this.config.allowedMimeTypes.join(\", \")}`);\n }\n }\n }\n\n /**\n * Get the bucket name - either from parameter or config\n */\n private getBucket(bucket?: string): string {\n // \"default\" is a logical bucket name used by local storage;\n // for S3 it should resolve to the configured bucket.\n if (!bucket || bucket === \"default\") return this.config.bucket;\n return bucket;\n }\n\n async putObject({\n file,\n key,\n metadata,\n bucket\n }: UploadFileProps): Promise<UploadFileResult> {\n this.validateFile(file);\n\n const usedBucket = this.getBucket(bucket);\n\n // Convert File to Buffer\n const arrayBuffer = await file.arrayBuffer();\n const buffer = Buffer.from(arrayBuffer);\n\n const s3 = await loadS3();\n const client = await this.getClient();\n\n const command = new s3.PutObjectCommand({\n Bucket: usedBucket,\n Key: key,\n Body: buffer,\n ContentType: file.type,\n Metadata: metadata ? this.flattenMetadata(metadata) : undefined\n });\n\n await client.send(command);\n\n return {\n key,\n bucket: usedBucket,\n storageUrl: `s3://${usedBucket}/${key}`\n };\n }\n\n /**\n * Flatten nested metadata to string values (S3 requirement)\n */\n private flattenMetadata(metadata: Record<string, unknown>): Record<string, string> {\n const flattened: Record<string, string> = {};\n for (const [key, value] of Object.entries(metadata)) {\n if (typeof value === \"string\") {\n flattened[key] = value;\n } else if (value !== undefined && value !== null) {\n flattened[key] = JSON.stringify(value);\n }\n }\n return flattened;\n }\n\n async getSignedUrl(key: string, bucket?: string): Promise<DownloadConfig> {\n // Handle s3:// and gs:// URLs for backward compatibility\n let resolvedPath = key;\n let resolvedBucket = this.getBucket(bucket);\n\n const match = key.match(/^(s3|gs):\\/\\//);\n if (match) {\n const protocolLength = match[0].length;\n const withoutProtocol = key.substring(protocolLength);\n const firstSlash = withoutProtocol.indexOf(\"/\");\n if (firstSlash > 0) {\n resolvedBucket = withoutProtocol.substring(0, firstSlash);\n resolvedPath = withoutProtocol.substring(firstSlash + 1);\n }\n }\n\n try {\n const s3 = await loadS3();\n const presigner = await loadPresigner();\n const client = await this.getClient();\n\n // First check if the object exists and get metadata\n const headCommand = new s3.HeadObjectCommand({\n Bucket: resolvedBucket,\n Key: resolvedPath\n });\n\n const headResult = await client.send(headCommand);\n\n // Generate a signed URL\n const getCommand = new s3.GetObjectCommand({\n Bucket: resolvedBucket,\n Key: resolvedPath\n });\n\n const expiresIn = this.config.signedUrlExpiration ?? 3600;\n const url = await presigner.getSignedUrl(client, getCommand, { expiresIn });\n\n const metadata: DownloadMetadata = {\n bucket: resolvedBucket,\n fullPath: resolvedPath,\n name: resolvedPath.split(\"/\").pop() || resolvedPath,\n size: headResult.ContentLength || 0,\n contentType: headResult.ContentType || \"application/octet-stream\",\n customMetadata: headResult.Metadata || {}\n };\n\n return {\n url,\n metadata\n };\n } catch (error: unknown) {\n const s3Error = error as { name?: string; $metadata?: { httpStatusCode?: number } };\n if (s3Error.name === \"NotFound\" || s3Error.$metadata?.httpStatusCode === 404) {\n return {\n url: null,\n fileNotFound: true\n };\n }\n throw error;\n }\n }\n\n async getObject(key: string, bucket?: string): Promise<File | null> {\n // Handle s3:// and gs:// URLs\n let resolvedPath = key;\n let resolvedBucket = this.getBucket(bucket);\n\n const match = key.match(/^(s3|gs):\\/\\//);\n if (match) {\n const protocolLength = match[0].length;\n const withoutProtocol = key.substring(protocolLength);\n const firstSlash = withoutProtocol.indexOf(\"/\");\n if (firstSlash > 0) {\n resolvedBucket = withoutProtocol.substring(0, firstSlash);\n resolvedPath = withoutProtocol.substring(firstSlash + 1);\n }\n }\n\n try {\n const s3 = await loadS3();\n const client = await this.getClient();\n\n const command = new s3.GetObjectCommand({\n Bucket: resolvedBucket,\n Key: resolvedPath\n });\n\n const response = await client.send(command);\n\n if (!response.Body) {\n return null;\n }\n\n // Convert stream to buffer\n const chunks: Uint8Array[] = [];\n for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {\n chunks.push(chunk);\n }\n const buffer = Buffer.concat(chunks);\n\n const contentType = response.ContentType || \"application/octet-stream\";\n const fileName = resolvedPath.split(\"/\").pop() || resolvedPath;\n\n const blob = new Blob([buffer], { type: contentType });\n // The object's own version. Left out, `File` stamps `Date.now()`,\n // and the routes build the `ETag`, `Last-Modified` and the\n // transform cache key from this — so every read was a new version:\n // no 304, no cache hit, and a fresh rendition written per request.\n return new File([blob], fileName, {\n type: contentType,\n lastModified: response.LastModified?.getTime()\n });\n } catch (error: unknown) {\n const s3Error = error as { name?: string; $metadata?: { httpStatusCode?: number } };\n if (s3Error.name === \"NoSuchKey\" || s3Error.$metadata?.httpStatusCode === 404) {\n return null;\n }\n throw error;\n }\n }\n\n async deleteObject(key: string, bucket?: string): Promise<void> {\n // Handle s3:// and gs:// URLs\n let resolvedPath = key;\n let resolvedBucket = this.getBucket(bucket);\n\n const match = key.match(/^(s3|gs):\\/\\//);\n if (match) {\n const protocolLength = match[0].length;\n const withoutProtocol = key.substring(protocolLength);\n const firstSlash = withoutProtocol.indexOf(\"/\");\n if (firstSlash > 0) {\n resolvedBucket = withoutProtocol.substring(0, firstSlash);\n resolvedPath = withoutProtocol.substring(firstSlash + 1);\n }\n }\n\n const s3 = await loadS3();\n const client = await this.getClient();\n\n const command = new s3.DeleteObjectCommand({\n Bucket: resolvedBucket,\n Key: resolvedPath\n });\n\n // Deleting something that is already gone is the outcome the caller\n // wanted, so it is not an error. AWS itself answers DeleteObject with\n // 204 for a key that never existed, but S3-compatible backends (MinIO,\n // Ceph, R2 in some configurations) do raise NoSuchKey — and the local\n // controller has always returned quietly in this case. Without this,\n // which of those a deployment happens to run decides whether\n // `DELETE /api/storage/:path` on a stale reference is a 200 or a 500.\n try {\n await client.send(command);\n } catch (error: unknown) {\n const s3Error = error as { name?: string; $metadata?: { httpStatusCode?: number } };\n if (s3Error.name === \"NoSuchKey\" || s3Error.$metadata?.httpStatusCode === 404) {\n return;\n }\n throw error;\n }\n }\n\n async listObjects(prefix: string, options?: {\n bucket?: string;\n maxResults?: number;\n pageToken?: string;\n }): Promise<StorageListResult> {\n const resolvedBucket = this.getBucket(options?.bucket);\n\n const s3 = await loadS3();\n const client = await this.getClient();\n\n const folderPrefix = listingPrefix(prefix);\n const command = new s3.ListObjectsV2Command({\n Bucket: resolvedBucket,\n Prefix: folderPrefix,\n MaxKeys: options?.maxResults ?? 1000,\n ContinuationToken: options?.pageToken,\n Delimiter: \"/\" // This gives us folder-like behavior\n });\n\n const response = await client.send(command);\n\n // The folder's own marker — the zero-byte `folder/` object that\n // `POST /folder` writes — is in `Contents` when the listed prefix is\n // that folder. It is how the folder exists, not something in it, and\n // listed it is an entry with an empty name. Local storage has no such\n // object to list.\n const objects = (response.Contents || []).filter(obj => obj.Key !== folderPrefix);\n const items: StorageReference[] = objects.map(obj => ({\n bucket: resolvedBucket,\n fullPath: obj.Key || \"\",\n name: (obj.Key || \"\").split(\"/\").pop() || \"\",\n parent: null as never,\n root: null as never,\n toString: () => `s3://${resolvedBucket}/${obj.Key}`\n }));\n\n const prefixes: StorageReference[] = (response.CommonPrefixes || []).map(common => {\n // Without the trailing slash S3 puts on a common prefix, so a\n // `fullPath` from a listing is a key any controller accepts.\n const folder = folderKey(common.Prefix || \"\");\n return {\n bucket: resolvedBucket,\n fullPath: folder,\n name: folder.split(\"/\").pop() || \"\",\n parent: null as never,\n root: null as never,\n toString: () => `s3://${resolvedBucket}/${folder}`\n };\n });\n\n return {\n items,\n prefixes,\n nextPageToken: response.NextContinuationToken\n };\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type CollectionPatch } from "@rebasepro/types";
|
|
1
2
|
/**
|
|
2
3
|
* Move presentation keys into the `admin` block.
|
|
3
4
|
*
|
|
@@ -59,6 +60,22 @@ export declare class AstSchemaEditor {
|
|
|
59
60
|
* Resolve a file path and ensure it falls within the collectionsDir.
|
|
60
61
|
*/
|
|
61
62
|
private safePath;
|
|
63
|
+
/**
|
|
64
|
+
* Bring the in-memory project back in line with the disk.
|
|
65
|
+
*
|
|
66
|
+
* The source-only editor builds ONE of these at boot and serves every
|
|
67
|
+
* request with it, and ts-morph reads a file once and then trusts its
|
|
68
|
+
* copy. `project.save()` writes that copy back whole, so anything that
|
|
69
|
+
* reached the disk in between — the live editor's committed change (it
|
|
70
|
+
* builds a fresh editor per request), or the developer's own edit in the
|
|
71
|
+
* IDE — was overwritten by the boot-time copy plus this edit. In `rebase
|
|
72
|
+
* dev` the tsx-watch restart usually closed the window; outside watch mode
|
|
73
|
+
* the loss was permanent.
|
|
74
|
+
*
|
|
75
|
+
* Called at the top of every public method, so each edit applies to what
|
|
76
|
+
* is on disk now.
|
|
77
|
+
*/
|
|
78
|
+
private syncWithDisk;
|
|
62
79
|
private getCollectionFile;
|
|
63
80
|
/**
|
|
64
81
|
* Find the object literal a collection is declared with, through whatever
|
|
@@ -85,6 +102,18 @@ export declare class AstSchemaEditor {
|
|
|
85
102
|
private findProperty;
|
|
86
103
|
private static quoteKey;
|
|
87
104
|
private static isPlainObject;
|
|
105
|
+
/**
|
|
106
|
+
* The field that identifies each element of an array of objects, so an
|
|
107
|
+
* old element can be found again in the new array.
|
|
108
|
+
*
|
|
109
|
+
* Arrays travel whole (see `collection_patch.ts`), and an element's
|
|
110
|
+
* position is not its identity — adding an entity action at the front
|
|
111
|
+
* shifts every other one. `key`, `id`, `relationName`, `name`: the first
|
|
112
|
+
* one every new element carries, distinct, wins.
|
|
113
|
+
*/
|
|
114
|
+
private static identityField;
|
|
115
|
+
/** The old array's object elements, keyed by `field`'s literal value. */
|
|
116
|
+
private oldElementsBy;
|
|
88
117
|
private convertJsonToAstString;
|
|
89
118
|
/**
|
|
90
119
|
* Write only the keys the patch names, leaving every sibling alone.
|
|
@@ -95,7 +124,18 @@ export declare class AstSchemaEditor {
|
|
|
95
124
|
* and kanban config in the same write.
|
|
96
125
|
*/
|
|
97
126
|
private mergeIntoObjectLiteral;
|
|
127
|
+
/**
|
|
128
|
+
* Drop whatever a refused edit left in memory.
|
|
129
|
+
*
|
|
130
|
+
* One editor serves every request, and `project.save()` writes every file
|
|
131
|
+
* with unsaved changes. So an edit refused halfway through — a relation with
|
|
132
|
+
* no target, found after the name was already rewritten and an import
|
|
133
|
+
* added — reached the disk with the next save of some other collection, and
|
|
134
|
+
* a file refused as it was being created appeared anyway.
|
|
135
|
+
*/
|
|
136
|
+
private forgetUnsaved;
|
|
98
137
|
saveProperty(collectionId: string, propertyKey: string, propertyConfig: Record<string, unknown>): Promise<void>;
|
|
138
|
+
private writeProperty;
|
|
99
139
|
deleteProperty(collectionId: string, propertyKey: string): Promise<void>;
|
|
100
140
|
/**
|
|
101
141
|
* Write a collection back to its file.
|
|
@@ -111,6 +151,69 @@ export declare class AstSchemaEditor {
|
|
|
111
151
|
saveCollection(collectionId: string, collectionData: Record<string, unknown>, options?: {
|
|
112
152
|
partial?: boolean;
|
|
113
153
|
}): Promise<void>;
|
|
154
|
+
/**
|
|
155
|
+
* Write what changed about a collection, and nothing else.
|
|
156
|
+
*
|
|
157
|
+
* The patch is the difference between the collection the panel loaded and
|
|
158
|
+
* the one it is saving (`diffCollections` in `@rebasepro/types`), so it
|
|
159
|
+
* names only keys the person changed. Each is written where it is; every
|
|
160
|
+
* other byte of the file — imports, comments, formatting, handlers,
|
|
161
|
+
* properties shared from another module — is left as it was.
|
|
162
|
+
*
|
|
163
|
+
* A key whose value in the file is code — `status: statusProperty`,
|
|
164
|
+
* `enum: LOCALE_ENUM`, a spread — is not overwritten with the JSON the
|
|
165
|
+
* panel saw of it, which is what the whole-collection save did. The edit
|
|
166
|
+
* is refused, naming the code, so it can be made where that code lives.
|
|
167
|
+
*
|
|
168
|
+
* All or nothing: a refused operation leaves the file as it was, including
|
|
169
|
+
* the operations before it.
|
|
170
|
+
*/
|
|
171
|
+
applyPatch(collectionId: string, patch: CollectionPatch): Promise<void>;
|
|
172
|
+
private writePatch;
|
|
173
|
+
private applyPatchOp;
|
|
174
|
+
/**
|
|
175
|
+
* A patched value with every relation target it carries turned back into
|
|
176
|
+
* source — `() => xCollection` plus its import — according to where the
|
|
177
|
+
* path lands: a relation's `target`, a relation, a property, or a map of
|
|
178
|
+
* properties.
|
|
179
|
+
*/
|
|
180
|
+
private patchValueAsSource;
|
|
181
|
+
/** A member of an object literal by name: an assignment, a shorthand, a method. */
|
|
182
|
+
private findMember;
|
|
183
|
+
private spreadIn;
|
|
184
|
+
/** `x as const`, `x satisfies T`, `(x)` — the value inside. */
|
|
185
|
+
private static unwrapExpression;
|
|
186
|
+
/**
|
|
187
|
+
* Whether a value in the file is data the panel's JSON fully describes —
|
|
188
|
+
* a literal — rather than code it saw only the result of.
|
|
189
|
+
*/
|
|
190
|
+
private static isLiteralValue;
|
|
191
|
+
/** "`properties.status` is `statusProperty` in posts.ts…" */
|
|
192
|
+
private codeRefusal;
|
|
193
|
+
private writeCollection;
|
|
194
|
+
/**
|
|
195
|
+
* Every property relation's `target`, as source.
|
|
196
|
+
*
|
|
197
|
+
* The collection-level `relations` array has {@link writeRelations}. A
|
|
198
|
+
* relation declared on a property went through `convertJsonToAstString`
|
|
199
|
+
* like any other value, so a target the property form had picked — a slug,
|
|
200
|
+
* which is all JSON can carry — was written as the string literal
|
|
201
|
+
* `target: "authors"`, and the next boot threw in `resolveRelation` for every
|
|
202
|
+
* collection in the directory. The same rule applies here as there: a slug
|
|
203
|
+
* becomes `() => xCollection` plus its import, an arrow returning one
|
|
204
|
+
* identifier is written through, and a relation sent without a target keeps
|
|
205
|
+
* the one already in the file — or is refused, when there is none.
|
|
206
|
+
*
|
|
207
|
+
* Walks into `map` properties, an array's `of` and a block's `oneOf`, which
|
|
208
|
+
* the registry resolves relations in too. `oldProperties` is the same
|
|
209
|
+
* properties object in the file, when there is one.
|
|
210
|
+
*/
|
|
211
|
+
private relationTargetsAsSource;
|
|
212
|
+
private propertyRelationTargetsAsSource;
|
|
213
|
+
/** The initializer of `obj[key]` in the file, when it is written as a property assignment. */
|
|
214
|
+
private initializerOf;
|
|
215
|
+
/** `obj[key]` in the file, when it is an object literal. */
|
|
216
|
+
private objectAt;
|
|
114
217
|
/**
|
|
115
218
|
* Write the collection-level `relations` array.
|
|
116
219
|
*
|
|
@@ -133,5 +236,28 @@ export declare class AstSchemaEditor {
|
|
|
133
236
|
private targetThunk;
|
|
134
237
|
private hasDefaultImport;
|
|
135
238
|
private getDefaultExportName;
|
|
136
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Delete a collection's file, and take it out of `index.ts`.
|
|
241
|
+
*
|
|
242
|
+
* This used to unlink `<slug>.ts` and stop. Every scaffold's `index.ts`
|
|
243
|
+
* imports every collection, so the next boot failed for the whole project
|
|
244
|
+
* ("Cannot find module …/tags"), and a collection another one linked to
|
|
245
|
+
* took that one down with it. Now:
|
|
246
|
+
*
|
|
247
|
+
* - a file any other module imports is refused, naming the importers — a
|
|
248
|
+
* relation to it has to go first, and that is a change the person makes;
|
|
249
|
+
* - `index.ts`'s import of it is removed, and so is each place it is listed
|
|
250
|
+
* in an array; any other use there is refused, naming the line;
|
|
251
|
+
* - the file is deleted.
|
|
252
|
+
*
|
|
253
|
+
* Only the source. The table and its rows are not touched.
|
|
254
|
+
*
|
|
255
|
+
* Returns what changed, as absolute paths, so the caller can commit it.
|
|
256
|
+
*/
|
|
257
|
+
deleteCollection(collectionId: string): Promise<{
|
|
258
|
+
deleted: string;
|
|
259
|
+
changed: string[];
|
|
260
|
+
}>;
|
|
261
|
+
/** The import and export declarations in `file` whose relative specifier resolves to `targetPath`. */
|
|
262
|
+
private declarationsOf;
|
|
137
263
|
}
|
package/dist/api/errors.d.ts
CHANGED
|
@@ -23,6 +23,15 @@ export declare function schemaDriftRemedy(): {
|
|
|
23
23
|
short: string;
|
|
24
24
|
lines: string[];
|
|
25
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Did the database refuse a value as unreadable for its column's type —
|
|
28
|
+
* SQLSTATE class 22, anywhere down the cause chain?
|
|
29
|
+
*
|
|
30
|
+
* For a lookup by an id the caller supplied, that is an answer rather than a
|
|
31
|
+
* failure: `abc` against a UUID key names no row, exactly as a well-formed id
|
|
32
|
+
* nobody has does.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isDataException(error: unknown): boolean;
|
|
26
35
|
/**
|
|
27
36
|
* Standardized API error class.
|
|
28
37
|
* Throw this from any route handler — the errorHandler middleware
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* — `applySchemaChange` re-checks rather than trusting the plan it is handed.
|
|
30
30
|
*/
|
|
31
31
|
import { Hono } from "hono";
|
|
32
|
-
import { type CollectionConfig, type DatabaseAdmin, type SchemaCommitPaths } from "@rebasepro/types";
|
|
32
|
+
import { type CollectionPatch, type CollectionConfig, type DatabaseAdmin, type SchemaCommitPaths } from "@rebasepro/types";
|
|
33
33
|
import { HonoEnv } from "./types.js";
|
|
34
34
|
import { type SchemaEditRepository } from "../schema-edit/apply-schema-change.js";
|
|
35
35
|
import { type SchemaEditPolicy } from "../schema-edit/schema-edit-permissions.js";
|
|
@@ -87,8 +87,8 @@ export interface LiveSchemaRoutesConfig {
|
|
|
87
87
|
*
|
|
88
88
|
* Absent for a project that *is* the repository, which is what the defaults
|
|
89
89
|
* describe. Present for one in a subdirectory, where the defaults would
|
|
90
|
-
* write `backend/`
|
|
91
|
-
*
|
|
90
|
+
* write `backend/` beside `.git` and leave the project's real generated
|
|
91
|
+
* file untouched — committing a source change alongside a stale
|
|
92
92
|
* schema, which is the failure committing the whole change exists to avoid.
|
|
93
93
|
*/
|
|
94
94
|
commitPaths?: Partial<SchemaCommitPaths>;
|
|
@@ -97,8 +97,8 @@ export interface LiveSchemaRoutesConfig {
|
|
|
97
97
|
* environment.
|
|
98
98
|
*
|
|
99
99
|
* If it does, applying here is not the whole job: the DDL runs against
|
|
100
|
-
* *this* database and
|
|
101
|
-
*
|
|
100
|
+
* *this* database and the collection is committed, but no migration is
|
|
101
|
+
* written — a server cannot mint one, since that needs Atlas and a
|
|
102
102
|
* throwaway database. The next environment built by replaying migrations
|
|
103
103
|
* would not have this change, and nothing would have said so.
|
|
104
104
|
*/
|
|
@@ -111,11 +111,31 @@ export interface LiveSchemaRoutesConfig {
|
|
|
111
111
|
*/
|
|
112
112
|
policy?: SchemaEditPolicy;
|
|
113
113
|
}
|
|
114
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* What the panel posts about one collection.
|
|
116
|
+
*
|
|
117
|
+
* For a collection that exists, a `patch`: what the person changed, as the
|
|
118
|
+
* difference between the JSON the editor loaded and the JSON it is saving (see
|
|
119
|
+
* `collection_patch.ts` in `@rebasepro/types`). The whole collection travelled
|
|
120
|
+
* here once, and the write rewrote every key it carried and deleted whatever
|
|
121
|
+
* JSON had dropped — handlers, shared properties, imported enums, comments.
|
|
122
|
+
*
|
|
123
|
+
* For a collection that does not exist yet, the whole `collection`. A caller
|
|
124
|
+
* that sends a whole collection for one that exists has it turned into the
|
|
125
|
+
* patch of what differs (see {@link asPatch}).
|
|
126
|
+
*/
|
|
115
127
|
export interface ProposedChange {
|
|
116
128
|
collectionId: string;
|
|
117
|
-
/** The whole collection
|
|
118
|
-
collection
|
|
129
|
+
/** The whole collection — only for one that does not exist yet. */
|
|
130
|
+
collection?: Record<string, unknown>;
|
|
131
|
+
/** What changed about an existing collection. */
|
|
132
|
+
patch?: CollectionPatch;
|
|
133
|
+
/**
|
|
134
|
+
* Delete the collection's source: its file, and its entry in `index.ts`.
|
|
135
|
+
* Planned like any change — the table and its rows stay, and say so — and
|
|
136
|
+
* committed source-only.
|
|
137
|
+
*/
|
|
138
|
+
remove?: true;
|
|
119
139
|
}
|
|
120
140
|
/**
|
|
121
141
|
* The git identity for the caller.
|
|
@@ -128,9 +148,33 @@ export declare function commitAuthor(user: unknown): {
|
|
|
128
148
|
name: string;
|
|
129
149
|
email: string;
|
|
130
150
|
} | undefined;
|
|
151
|
+
/**
|
|
152
|
+
* The change as a patch against the collection as it is.
|
|
153
|
+
*
|
|
154
|
+
* A whole collection posted for one that exists is the difference from the
|
|
155
|
+
* current one: a caller that sends JSON cannot then delete what JSON could not
|
|
156
|
+
* carry. A key it left out is removed only when its value is pure data — a
|
|
157
|
+
* key holding code (`callbacks`, a handler, a component) is not something a
|
|
158
|
+
* JSON caller could have meant to drop.
|
|
159
|
+
*/
|
|
160
|
+
export declare function asPatch(existing: CollectionConfig, change: ProposedChange): CollectionPatch;
|
|
131
161
|
/**
|
|
132
162
|
* The proposed collection set: everything as it is, with one collection
|
|
133
163
|
* replaced or added.
|
|
164
|
+
*
|
|
165
|
+
* The replacement arrives as JSON, and a relation's `target` is a thunk —
|
|
166
|
+
* `JSON.stringify` drops it, so every relation the panel did not touch comes
|
|
167
|
+
* back with no target, and one picked in the property form names its target by
|
|
168
|
+
* slug. The planner calls `target()` on every relation in the set, so as it
|
|
169
|
+
* arrived it threw on the first one, and no change of any kind could be planned
|
|
170
|
+
* for a collection that had a relation.
|
|
171
|
+
*
|
|
172
|
+
* So each target is made callable again, the way the AST editor makes it
|
|
173
|
+
* source again when it writes the file: a slug becomes a lookup in the
|
|
174
|
+
* proposed set — so a link to the collection being edited reaches its new
|
|
175
|
+
* version — and a missing target is the one the same relation has now. A
|
|
176
|
+
* target that is neither is refused here, as the caller's mistake, rather than
|
|
177
|
+
* thrown from inside the planner as a 500.
|
|
134
178
|
*/
|
|
135
179
|
export declare function proposedCollections(current: CollectionConfig[], change: ProposedChange): CollectionConfig[];
|
|
136
180
|
export declare function createLiveSchemaRoutes(config: LiveSchemaRoutesConfig): Hono<HonoEnv>;
|