@3sln/trove 0.0.5 → 0.0.7

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 CHANGED
@@ -163,6 +163,12 @@ TROVE_S3_ACCESS_KEY_ID=… # or AWS_ACCESS_KEY_ID
163
163
  TROVE_S3_SECRET_ACCESS_KEY=… # or AWS_SECRET_ACCESS_KEY
164
164
  TROVE_S3_PATH_STYLE=true # MinIO / custom endpoints
165
165
 
166
+ # Which store types a COLLECTION may be created on. Defaults to everything this
167
+ # runtime registered; naming a subset takes the rest off the collection form and
168
+ # refuses them. Worth setting on Workers, where `memory` is offered because it is
169
+ # portable but produces a collection that loses its uploads on isolate recycle.
170
+ TROVE_STORAGE_DRIVERS=s3 # subset of: memory | filesystem | s3
171
+
166
172
  # Metadata (file tree + facets)
167
173
  TROVE_METADATA=sqlite # memory | sqlite
168
174
  TROVE_DB_PATH=./data/trove.db
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
6
6
  "repository": {
@@ -49,9 +49,19 @@ export function portableDrivers() {
49
49
  help: 'MinIO and most self-hosted endpoints need this.',
50
50
  },
51
51
  ],
52
- // S3Storage takes the config flat; the old switch passed `cfg.s3`, which meant a
53
- // collection record had to nest its own settings one level deeper than every
54
- // other driver for no reason a user could see.
52
+ // Two config shapes reach here and both have to work.
53
+ //
54
+ // Flat — `{ driver: 's3', bucket: }` is what `fields` above describes and what
55
+ // the collection form posts. Nested under `s3` is what `configFromEnv` produces and
56
+ // what every collection record written before this driver existed holds, because the
57
+ // switch this replaced read `cfg.s3` and nothing else.
58
+ //
59
+ // Normalising rather than only handling it in `create` is the point: validation runs
60
+ // against the normalised shape, so a nested config is no longer refused for a missing
61
+ // top-level `bucket` that was about to be spread in anyway. That refusal broke every
62
+ // environment-configured S3 deployment at startup, which is as loud as a bug gets and
63
+ // still took a local run to see, because no test used the env shape.
64
+ normalize: (cfg) => ({ ...cfg, ...(cfg.s3 || {}) }),
55
65
  create: (cfg) => new S3Storage({
56
66
  bucket: cfg.bucket,
57
67
  region: cfg.region || 'auto',
@@ -60,7 +70,6 @@ export function portableDrivers() {
60
70
  secretAccessKey: cfg.secretAccessKey,
61
71
  sessionToken: cfg.sessionToken,
62
72
  forcePathStyle: cfg.forcePathStyle === true || cfg.forcePathStyle === 'true',
63
- ...(cfg.s3 || {}),
64
73
  }),
65
74
  },
66
75
  {
@@ -56,6 +56,9 @@ export class StorageDriverRegistry {
56
56
  * @param {string} driver.label for a form
57
57
  * @param {string} [driver.description] one line about what it is
58
58
  * @param {DriverField[]} [driver.fields]
59
+ * @param {(config: object) => object} [driver.normalize] accept an older or alternative
60
+ * config shape, returning the one `fields` describes. Runs before validation, so the
61
+ * shape checked is the shape built from.
59
62
  * @param {(config: object) => StorageBackend} driver.create
60
63
  */
61
64
  register(driver) {
@@ -82,6 +85,7 @@ export class StorageDriverRegistry {
82
85
  placeholder: f.placeholder || '',
83
86
  help: f.help || '',
84
87
  })),
88
+ normalize: driver.normalize || null,
85
89
  create: driver.create,
86
90
  });
87
91
  return this;
@@ -95,9 +99,30 @@ export class StorageDriverRegistry {
95
99
  return [...this._drivers.keys()];
96
100
  }
97
101
 
98
- /** What a client needs to render a form. No `create`, because that is not data. */
102
+ /**
103
+ * One registered driver, `create` included — unlike `describe()`, which deliberately
104
+ * strips it because it answers a client.
105
+ *
106
+ * For copying a driver into another registry, which is how a deployment narrows the set
107
+ * it offers: a registry has no `unregister`, so narrowing rebuilds from what survived
108
+ * rather than removing from what did not. Keeping removal out of this class is the point
109
+ * — a driver disappearing from a live registry is a store that stops being buildable
110
+ * while collections still reference it.
111
+ */
112
+ driver(key) {
113
+ return this._drivers.get(key);
114
+ }
115
+
116
+ /**
117
+ * What a client needs to render a form.
118
+ *
119
+ * Neither `create` nor `normalize`: both are behaviour, not data. JSON.stringify would
120
+ * drop them anyway, which is exactly why they are removed here instead — a describe()
121
+ * whose result is only serialisable by accident is one that leaks the next function
122
+ * somebody adds into every in-process consumer.
123
+ */
99
124
  describe() {
100
- return [...this._drivers.values()].map(({ create, ...rest }) => rest);
125
+ return [...this._drivers.values()].map(({ create, normalize, ...rest }) => rest);
101
126
  }
102
127
 
103
128
  /**
@@ -105,6 +130,13 @@ export class StorageDriverRegistry {
105
130
  *
106
131
  * An unknown driver throws, and says what IS available. This is the arm that used to
107
132
  * return an in-memory store.
133
+ *
134
+ * `normalize` runs FIRST, so a driver that accepts more than one config shape validates
135
+ * the shape it will actually build from. Without it, required-field checks are performed
136
+ * against a config the driver was about to rewrite — which is precisely how S3 broke:
137
+ * `configFromEnv` nests its settings under `s3`, `create` spread that back out, and the
138
+ * check in between looked for a top-level `bucket` that was never going to be there and
139
+ * refused every environment-configured S3 deployment at startup.
108
140
  */
109
141
  build(config) {
110
142
  const key = config?.driver;
@@ -115,12 +147,13 @@ export class StorageDriverRegistry {
115
147
  `Unknown storage driver "${key}" — this deployment has: ${this.keys().join(', ') || 'none'}`,
116
148
  );
117
149
  }
150
+ const cfg = driver.normalize ? driver.normalize(config) : config;
118
151
  for (const f of driver.fields) {
119
- if (f.required && (config[f.name] == null || config[f.name] === '')) {
152
+ if (f.required && (cfg[f.name] == null || cfg[f.name] === '')) {
120
153
  throw TroveError.invalid(`Storage driver "${key}" requires "${f.name}"`);
121
154
  }
122
155
  }
123
- const backend = driver.create(config);
156
+ const backend = driver.create(cfg);
124
157
  if (!(backend instanceof StorageBackend)) {
125
158
  throw TroveError.invalid(`Storage driver "${key}" did not return a StorageBackend`);
126
159
  }
@@ -58,13 +58,41 @@ const resolve = (value, BaseClass, build) =>
58
58
  * portable ones — so a deployment adds Filesystem (Node/Bun), or a driver written
59
59
  * entirely outside this package, by naming it at the entry point. What is not registered
60
60
  * is not offered and cannot be built.
61
+ *
62
+ * `config.allowedStorageDrivers` (TROVE_STORAGE_DRIVERS) narrows the result to an explicit
63
+ * set. Adding drivers is a code decision made by an entry point, which knows what the
64
+ * runtime can run; REMOVING them is an operator decision about one deployment, and needs
65
+ * to be reachable from configuration alone. A drive on Workers is the case in point: memory
66
+ * is portable, so it is offered, and choosing it there produces a collection that accepts
67
+ * uploads and loses them when the isolate is recycled. `TROVE_STORAGE_DRIVERS=s3` takes it
68
+ * off the menu without a fork of the entry point.
69
+ *
70
+ * A name that matches nothing throws rather than narrowing to nothing — a typo that left a
71
+ * drive with no way to make a collection would be a puzzle, not a message.
61
72
  */
62
73
  export function storageRegistry(config = {}) {
63
74
  if (config.storageRegistry instanceof StorageDriverRegistry) return config.storageRegistry;
64
75
  if (config.storageDrivers instanceof StorageDriverRegistry) return config.storageDrivers;
65
76
  const registry = new StorageDriverRegistry(portableDrivers());
66
77
  for (const d of config.storageDrivers || []) registry.register(d);
67
- return registry;
78
+
79
+ const allowed = config.allowedStorageDrivers;
80
+ if (!allowed?.length) return registry;
81
+ const unknown = allowed.filter((k) => !registry.has(k));
82
+ if (unknown.length) {
83
+ throw TroveError.invalid(
84
+ `TROVE_STORAGE_DRIVERS names ${unknown.map((k) => `"${k}"`).join(', ')}, which `
85
+ + `${unknown.length === 1 ? 'is not a driver' : 'are not drivers'} this deployment has: `
86
+ + `${registry.keys().join(', ')}`,
87
+ );
88
+ }
89
+ // Rebuilt from the descriptors that survived rather than mutated, so a registry never
90
+ // has to support removal — and the drivers keep their registration order.
91
+ const narrowed = new StorageDriverRegistry();
92
+ for (const key of registry.keys()) {
93
+ if (allowed.includes(key)) narrowed.register(registry.driver(key));
94
+ }
95
+ return narrowed;
68
96
  }
69
97
 
70
98
  /**
@@ -624,6 +624,14 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
624
624
  if (config.storage.driver === 'filesystem') config.storage.root = env.TROVE_FS_ROOT || './data/objects';
625
625
  if (config.storage.driver === 's3') config.storage.s3 = s3FromEnv(env, 'TROVE_');
626
626
 
627
+ // Which store types a COLLECTION may be created on, if not all of the ones this entry
628
+ // point registered. `TROVE_STORAGE` picks the drive's own primary store; this restricts
629
+ // the menu the collection form offers and what `build` will accept — see
630
+ // engine/providers/core.js for why removal is configuration and addition is code.
631
+ if (env.TROVE_STORAGE_DRIVERS) {
632
+ config.allowedStorageDrivers = env.TROVE_STORAGE_DRIVERS.split(',').map((s) => s.trim()).filter(Boolean);
633
+ }
634
+
627
635
  config.metadata.driver = env.TROVE_METADATA || (config.storage.driver === 'memory' ? 'memory' : 'sqlite');
628
636
  config.metadata.path = env.TROVE_DB_PATH || './data/trove.db';
629
637