@objectstack/service-storage 4.0.5 → 4.1.1

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/dist/index.js CHANGED
@@ -480,6 +480,9 @@ var LocalStorageAdapter = class {
480
480
  }
481
481
  };
482
482
 
483
+ // src/storage-service-plugin.ts
484
+ init_s3_storage_adapter();
485
+
483
486
  // src/metadata-store.ts
484
487
  var StorageMetadataStore = class {
485
488
  constructor(engine) {
@@ -496,7 +499,7 @@ var StorageMetadataStore = class {
496
499
  this.files.set(full.id, full);
497
500
  if (this.engine) {
498
501
  try {
499
- await this.engine.insert("system_file", full);
502
+ await this.engine.insert("sys_file", full);
500
503
  } catch {
501
504
  }
502
505
  }
@@ -505,7 +508,7 @@ var StorageMetadataStore = class {
505
508
  async getFile(id) {
506
509
  if (this.engine) {
507
510
  try {
508
- const found = await this.engine.findOne("system_file", { where: { id } });
511
+ const found = await this.engine.findOne("sys_file", { where: { id } });
509
512
  if (found) return found;
510
513
  } catch {
511
514
  }
@@ -519,7 +522,7 @@ var StorageMetadataStore = class {
519
522
  this.files.set(id, merged);
520
523
  if (this.engine) {
521
524
  try {
522
- await this.engine.update("system_file", merged, { where: { id } });
525
+ await this.engine.update("sys_file", merged, { where: { id } });
523
526
  } catch {
524
527
  }
525
528
  }
@@ -529,7 +532,7 @@ var StorageMetadataStore = class {
529
532
  this.files.delete(id);
530
533
  if (this.engine) {
531
534
  try {
532
- await this.engine.delete("system_file", { where: { id } });
535
+ await this.engine.delete("sys_file", { where: { id } });
533
536
  } catch {
534
537
  }
535
538
  }
@@ -550,7 +553,7 @@ var StorageMetadataStore = class {
550
553
  this.sessions.set(full.id, full);
551
554
  if (this.engine) {
552
555
  try {
553
- await this.engine.insert("system_upload_session", full);
556
+ await this.engine.insert("sys_upload_session", full);
554
557
  } catch {
555
558
  }
556
559
  }
@@ -559,7 +562,7 @@ var StorageMetadataStore = class {
559
562
  async getSession(id) {
560
563
  if (this.engine) {
561
564
  try {
562
- const found = await this.engine.findOne("system_upload_session", { where: { id } });
565
+ const found = await this.engine.findOne("sys_upload_session", { where: { id } });
563
566
  if (found) return found;
564
567
  } catch {
565
568
  }
@@ -578,7 +581,7 @@ var StorageMetadataStore = class {
578
581
  this.sessions.set(id, merged);
579
582
  if (this.engine) {
580
583
  try {
581
- await this.engine.update("system_upload_session", merged, { where: { id } });
584
+ await this.engine.update("sys_upload_session", merged, { where: { id } });
582
585
  } catch {
583
586
  }
584
587
  }
@@ -588,7 +591,7 @@ var StorageMetadataStore = class {
588
591
  this.sessions.delete(id);
589
592
  if (this.engine) {
590
593
  try {
591
- await this.engine.delete("system_upload_session", { where: { id } });
594
+ await this.engine.delete("sys_upload_session", { where: { id } });
592
595
  } catch {
593
596
  }
594
597
  }
@@ -881,6 +884,28 @@ function registerStorageRoutes(httpServer, storage, store, opts = {}) {
881
884
  res.status(500).json({ error: err.message ?? "Internal error" });
882
885
  }
883
886
  });
887
+ httpServer.get(`${basePath}/files/:fileId`, async (req, res) => {
888
+ try {
889
+ const { fileId } = req.params;
890
+ const file = await store.getFile(fileId);
891
+ if (!file || file.status !== "committed") {
892
+ res.status(404).json({ error: "File not found or not committed" });
893
+ return;
894
+ }
895
+ let url;
896
+ if (storage.getPresignedDownload) {
897
+ const desc = await storage.getPresignedDownload(file.key, presignedTtl);
898
+ url = desc.downloadUrl;
899
+ } else if (storage.getSignedUrl) {
900
+ url = await storage.getSignedUrl(file.key, presignedTtl);
901
+ } else {
902
+ url = `${basePath}/_local/file/${encodeURIComponent(file.key)}`;
903
+ }
904
+ res.status(302).header("Location", url).send("");
905
+ } catch (err) {
906
+ res.status(500).json({ error: err.message ?? "Internal error" });
907
+ }
908
+ });
884
909
  httpServer.put(`${basePath}/_local/raw/:token`, async (req, res) => {
885
910
  try {
886
911
  const { token } = req.params;
@@ -933,7 +958,7 @@ function buildKey(scope, fileId, filename) {
933
958
  // src/objects/system-file.object.ts
934
959
  import { ObjectSchema, Field } from "@objectstack/spec/data";
935
960
  var SystemFile = ObjectSchema.create({
936
- name: "system_file",
961
+ name: "sys_file",
937
962
  label: "System File",
938
963
  pluralLabel: "System Files",
939
964
  icon: "file",
@@ -979,7 +1004,7 @@ var SystemFile = ObjectSchema.create({
979
1004
  label: "ACL",
980
1005
  options: [
981
1006
  { label: "Private", value: "private" },
982
- { label: "Public Read", value: "public-read" }
1007
+ { label: "Public Read", value: "public_read" }
983
1008
  ]
984
1009
  }),
985
1010
  status: Field.select({
@@ -1012,7 +1037,7 @@ var SystemFile = ObjectSchema.create({
1012
1037
  // src/objects/system-upload-session.object.ts
1013
1038
  import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
1014
1039
  var SystemUploadSession = ObjectSchema2.create({
1015
- name: "system_upload_session",
1040
+ name: "sys_upload_session",
1016
1041
  label: "System Upload Session",
1017
1042
  pluralLabel: "System Upload Sessions",
1018
1043
  icon: "upload-cloud",
@@ -1099,6 +1124,103 @@ var SystemUploadSession = ObjectSchema2.create({
1099
1124
  }
1100
1125
  });
1101
1126
 
1127
+ // src/swappable-storage-service.ts
1128
+ var SwappableStorageService = class {
1129
+ constructor(initial, onSwap) {
1130
+ this.inner = initial;
1131
+ this.onSwap = onSwap;
1132
+ }
1133
+ /** Replace the inner adapter. */
1134
+ swap(next) {
1135
+ const previous = this.inner;
1136
+ this.inner = next;
1137
+ this.onSwap?.(previous, next);
1138
+ }
1139
+ /** Expose the active inner adapter — primarily for tests. */
1140
+ getInner() {
1141
+ return this.inner;
1142
+ }
1143
+ upload(key, data, options) {
1144
+ return this.inner.upload(key, data, options);
1145
+ }
1146
+ download(key) {
1147
+ return this.inner.download(key);
1148
+ }
1149
+ delete(key) {
1150
+ return this.inner.delete(key);
1151
+ }
1152
+ exists(key) {
1153
+ return this.inner.exists(key);
1154
+ }
1155
+ getInfo(key) {
1156
+ return this.inner.getInfo(key);
1157
+ }
1158
+ list(prefix) {
1159
+ if (typeof this.inner.list !== "function") {
1160
+ return Promise.reject(new Error("Active storage adapter does not support list()"));
1161
+ }
1162
+ return this.inner.list(prefix);
1163
+ }
1164
+ getSignedUrl(key, expiresIn) {
1165
+ if (typeof this.inner.getSignedUrl !== "function") {
1166
+ return Promise.reject(new Error("Active storage adapter does not support getSignedUrl()"));
1167
+ }
1168
+ return this.inner.getSignedUrl(key, expiresIn);
1169
+ }
1170
+ getPresignedUpload(key, expiresIn, options) {
1171
+ if (typeof this.inner.getPresignedUpload !== "function") {
1172
+ return Promise.reject(new Error("Active storage adapter does not support getPresignedUpload()"));
1173
+ }
1174
+ return this.inner.getPresignedUpload(key, expiresIn, options);
1175
+ }
1176
+ getPresignedDownload(key, expiresIn) {
1177
+ if (typeof this.inner.getPresignedDownload !== "function") {
1178
+ return Promise.reject(new Error("Active storage adapter does not support getPresignedDownload()"));
1179
+ }
1180
+ return this.inner.getPresignedDownload(key, expiresIn);
1181
+ }
1182
+ initiateChunkedUpload(key, options) {
1183
+ if (typeof this.inner.initiateChunkedUpload !== "function") {
1184
+ return Promise.reject(new Error("Active storage adapter does not support initiateChunkedUpload()"));
1185
+ }
1186
+ return this.inner.initiateChunkedUpload(key, options);
1187
+ }
1188
+ uploadChunk(uploadId, partNumber, data) {
1189
+ if (typeof this.inner.uploadChunk !== "function") {
1190
+ return Promise.reject(new Error("Active storage adapter does not support uploadChunk()"));
1191
+ }
1192
+ return this.inner.uploadChunk(uploadId, partNumber, data);
1193
+ }
1194
+ completeChunkedUpload(uploadId, parts) {
1195
+ if (typeof this.inner.completeChunkedUpload !== "function") {
1196
+ return Promise.reject(new Error("Active storage adapter does not support completeChunkedUpload()"));
1197
+ }
1198
+ return this.inner.completeChunkedUpload(uploadId, parts);
1199
+ }
1200
+ abortChunkedUpload(uploadId) {
1201
+ if (typeof this.inner.abortChunkedUpload !== "function") {
1202
+ return Promise.reject(new Error("Active storage adapter does not support abortChunkedUpload()"));
1203
+ }
1204
+ return this.inner.abortChunkedUpload(uploadId);
1205
+ }
1206
+ /**
1207
+ * Verify a presigned HMAC token (LocalStorageAdapter-specific).
1208
+ *
1209
+ * `IStorageService` does not declare this method, but `storage-routes`
1210
+ * type-narrows the active storage to `LocalStorageAdapter` to handle the
1211
+ * `/_local/raw/:token` PUT and GET endpoints. Without a passthrough on
1212
+ * the swappable wrapper, the route sees `verifyToken === undefined` and
1213
+ * returns 501 even though the underlying local adapter supports it.
1214
+ */
1215
+ verifyToken(token, expectedOp) {
1216
+ const inner = this.inner;
1217
+ if (typeof inner.verifyToken !== "function") {
1218
+ throw new Error("Active storage adapter does not support verifyToken()");
1219
+ }
1220
+ return inner.verifyToken(token, expectedOp);
1221
+ }
1222
+ };
1223
+
1102
1224
  // src/storage-service-plugin.ts
1103
1225
  var StorageServicePlugin = class {
1104
1226
  constructor(options = {}) {
@@ -1109,22 +1231,57 @@ var StorageServicePlugin = class {
1109
1231
  this.store = null;
1110
1232
  this.options = { adapter: "local", ...options };
1111
1233
  }
1234
+ /** Build a concrete adapter from a values map (settings-derived). */
1235
+ async buildAdapterFromValues(values) {
1236
+ const adapter = String(values.adapter ?? "local");
1237
+ if (adapter === "s3") {
1238
+ const bucket = values.s3_bucket;
1239
+ const region = values.s3_region;
1240
+ if (!bucket || !region) {
1241
+ throw new Error("StorageServicePlugin: S3 adapter requires s3_bucket and s3_region");
1242
+ }
1243
+ const opts = {
1244
+ bucket,
1245
+ region,
1246
+ endpoint: values.s3_endpoint || void 0,
1247
+ accessKeyId: values.s3_access_key_id || void 0,
1248
+ secretAccessKey: values.s3_secret_access_key || void 0,
1249
+ forcePathStyle: !!values.s3_force_path_style
1250
+ };
1251
+ return new S3StorageAdapter(opts);
1252
+ }
1253
+ const rootDir = values.local_root || "./storage";
1254
+ return new LocalStorageAdapter({
1255
+ basePath: this.options.basePath ?? "/api/v1/storage",
1256
+ ...this.options.local ?? {},
1257
+ // settings value wins over any constructor-provided local.rootDir
1258
+ rootDir
1259
+ });
1260
+ }
1112
1261
  async init(ctx) {
1113
1262
  const adapter = this.options.adapter;
1263
+ let initial;
1114
1264
  if (adapter === "s3") {
1115
- const { S3StorageAdapter: S3StorageAdapter2 } = await Promise.resolve().then(() => (init_s3_storage_adapter(), s3_storage_adapter_exports));
1265
+ const { S3StorageAdapter: S3Ctor } = await Promise.resolve().then(() => (init_s3_storage_adapter(), s3_storage_adapter_exports));
1116
1266
  const s3Opts = this.options.s3;
1117
1267
  if (!s3Opts) {
1118
1268
  throw new Error('StorageServicePlugin: s3 options are required when adapter is "s3"');
1119
1269
  }
1120
- this.storage = new S3StorageAdapter2(s3Opts);
1270
+ initial = new S3Ctor(s3Opts);
1121
1271
  } else {
1122
1272
  const rootDir = this.options.local?.rootDir ?? "./storage";
1123
1273
  const basePath = this.options.basePath ?? "/api/v1/storage";
1124
- this.storage = new LocalStorageAdapter({ rootDir, basePath, ...this.options.local });
1274
+ initial = new LocalStorageAdapter({ rootDir, basePath, ...this.options.local });
1125
1275
  }
1276
+ this.storage = new SwappableStorageService(initial, (prev, next) => {
1277
+ const prevName = prev?.constructor?.name ?? "unknown";
1278
+ const nextName = next?.constructor?.name ?? "unknown";
1279
+ ctx.logger.warn(
1280
+ `StorageServicePlugin: storage adapter swapped (${prevName} \u2192 ${nextName}). Existing files were NOT migrated and may be unreachable through the new adapter.`
1281
+ );
1282
+ });
1126
1283
  ctx.registerService("file-storage", this.storage);
1127
- ctx.logger.info(`StorageServicePlugin: registered ${adapter} storage adapter`);
1284
+ ctx.logger.info(`StorageServicePlugin: registered ${adapter} storage adapter (swappable)`);
1128
1285
  try {
1129
1286
  ctx.getService("manifest").register({
1130
1287
  id: "com.objectstack.service.storage",
@@ -1138,31 +1295,101 @@ var StorageServicePlugin = class {
1138
1295
  }
1139
1296
  }
1140
1297
  async start(ctx) {
1141
- if (this.options.registerRoutes === false) return;
1142
1298
  ctx.hook("kernel:ready", async () => {
1143
- let httpServer = null;
1144
- try {
1145
- httpServer = ctx.getService("http-server");
1146
- } catch {
1147
- }
1148
- if (!httpServer || !this.storage) {
1149
- ctx.logger.warn(
1150
- 'StorageServicePlugin: no HTTP server available \u2014 REST routes not registered. File storage is still accessible programmatically via kernel.getService("file-storage").'
1151
- );
1152
- return;
1299
+ if (this.options.registerRoutes !== false) {
1300
+ let httpServer = null;
1301
+ try {
1302
+ httpServer = ctx.getService("http-server");
1303
+ } catch {
1304
+ }
1305
+ if (httpServer && this.storage) {
1306
+ let engine = null;
1307
+ try {
1308
+ engine = ctx.getService("objectql");
1309
+ } catch {
1310
+ }
1311
+ this.store = new StorageMetadataStore(engine);
1312
+ registerStorageRoutes(httpServer, this.storage, this.store, {
1313
+ basePath: this.options.basePath ?? "/api/v1/storage",
1314
+ presignedTtl: this.options.presignedTtl,
1315
+ sessionTtl: this.options.sessionTtl
1316
+ });
1317
+ ctx.logger.info(
1318
+ "StorageServicePlugin: REST routes registered at " + (this.options.basePath ?? "/api/v1/storage")
1319
+ );
1320
+ } else if (!httpServer) {
1321
+ ctx.logger.warn(
1322
+ 'StorageServicePlugin: no HTTP server available \u2014 REST routes not registered. File storage is still accessible programmatically via kernel.getService("file-storage").'
1323
+ );
1324
+ }
1153
1325
  }
1154
- let engine = null;
1326
+ if (this.options.bindToSettings === false) return;
1155
1327
  try {
1156
- engine = ctx.getService("objectql");
1328
+ const settings = ctx.getService("settings");
1329
+ if (!settings || typeof settings.createClient !== "function") return;
1330
+ const applySettings = async () => {
1331
+ if (!this.storage) return;
1332
+ try {
1333
+ const payload = await settings.getNamespace("storage");
1334
+ const values = {};
1335
+ for (const [k, v] of Object.entries(payload.values)) {
1336
+ values[k] = v?.value;
1337
+ }
1338
+ const hasAny = Object.values(values).some((v) => v !== void 0 && v !== null && v !== "");
1339
+ if (!hasAny) return;
1340
+ const next = await this.buildAdapterFromValues(values);
1341
+ this.storage.swap(next);
1342
+ } catch (err) {
1343
+ ctx.logger.warn(
1344
+ "StorageServicePlugin: failed to apply storage settings: " + (err?.message ?? err)
1345
+ );
1346
+ }
1347
+ };
1348
+ await applySettings();
1349
+ if (typeof settings.subscribe === "function") {
1350
+ settings.subscribe("storage", () => {
1351
+ void applySettings();
1352
+ });
1353
+ ctx.logger.info("StorageServicePlugin: bound to settings:changed for namespace=storage");
1354
+ }
1355
+ if (typeof settings.registerAction === "function" && this.storage) {
1356
+ const proxy = this.storage;
1357
+ settings.registerAction("storage", "test", async ({ values }) => {
1358
+ const probeKey = `__objectstack_probe__/${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1359
+ const probeBytes = Buffer.from(`probe@${(/* @__PURE__ */ new Date()).toISOString()}`, "utf-8");
1360
+ try {
1361
+ let target = proxy;
1362
+ if (values && Object.keys(values).length > 0) {
1363
+ try {
1364
+ target = await this.buildAdapterFromValues(values);
1365
+ } catch (err) {
1366
+ return { ok: false, severity: "error", message: err?.message ?? String(err) };
1367
+ }
1368
+ }
1369
+ await target.upload(probeKey, probeBytes, { contentType: "text/plain" });
1370
+ const got = await target.download(probeKey);
1371
+ if (!got || !Buffer.isBuffer(got) || got.toString("utf-8") !== probeBytes.toString("utf-8")) {
1372
+ return { ok: false, severity: "error", message: "Probe download did not match upload." };
1373
+ }
1374
+ await target.delete(probeKey);
1375
+ const adapter = String(values?.adapter ?? this.options.adapter ?? "local");
1376
+ return {
1377
+ ok: true,
1378
+ severity: "info",
1379
+ message: `Storage round-trip succeeded (adapter=${adapter}).`
1380
+ };
1381
+ } catch (err) {
1382
+ try {
1383
+ await proxy.delete(probeKey);
1384
+ } catch {
1385
+ }
1386
+ return { ok: false, severity: "error", message: err?.message ?? String(err) };
1387
+ }
1388
+ });
1389
+ ctx.logger.info("StorageServicePlugin: registered settings action storage/test");
1390
+ }
1157
1391
  } catch {
1158
1392
  }
1159
- this.store = new StorageMetadataStore(engine);
1160
- registerStorageRoutes(httpServer, this.storage, this.store, {
1161
- basePath: this.options.basePath ?? "/api/v1/storage",
1162
- presignedTtl: this.options.presignedTtl,
1163
- sessionTtl: this.options.sessionTtl
1164
- });
1165
- ctx.logger.info("StorageServicePlugin: REST routes registered at " + (this.options.basePath ?? "/api/v1/storage"));
1166
1393
  });
1167
1394
  }
1168
1395
  };
@@ -1174,6 +1401,7 @@ export {
1174
1401
  S3StorageAdapter,
1175
1402
  StorageMetadataStore,
1176
1403
  StorageServicePlugin,
1404
+ SwappableStorageService,
1177
1405
  SystemFile,
1178
1406
  SystemUploadSession,
1179
1407
  registerStorageRoutes