@better-media/framework 0.1.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/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/media.mjs +3 -0
- package/dist/index.d.mts +225 -0
- package/dist/index.d.ts +225 -0
- package/dist/index.js +1118 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1044 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +50 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core = require('@better-media/core');
|
|
4
|
+
var fs2 = require('fs/promises');
|
|
5
|
+
var crypto = require('crypto');
|
|
6
|
+
var adapterJobs = require('@better-media/adapter-jobs');
|
|
7
|
+
var path = require('path');
|
|
8
|
+
var fs = require('fs');
|
|
9
|
+
var os = require('os');
|
|
10
|
+
var stream = require('stream');
|
|
11
|
+
|
|
12
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
13
|
+
|
|
14
|
+
var fs2__default = /*#__PURE__*/_interopDefault(fs2);
|
|
15
|
+
var path__default = /*#__PURE__*/_interopDefault(path);
|
|
16
|
+
var os__default = /*#__PURE__*/_interopDefault(os);
|
|
17
|
+
|
|
18
|
+
var __defProp = Object.defineProperty;
|
|
19
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
20
|
+
var __esm = (fn, res) => function __init() {
|
|
21
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
22
|
+
};
|
|
23
|
+
var __export = (target, all) => {
|
|
24
|
+
for (var name in all)
|
|
25
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// src/core/lifecycle-engine.ts
|
|
29
|
+
var lifecycle_engine_exports = {};
|
|
30
|
+
__export(lifecycle_engine_exports, {
|
|
31
|
+
LifecycleEngine: () => LifecycleEngine,
|
|
32
|
+
createSecureContext: () => createSecureContext
|
|
33
|
+
});
|
|
34
|
+
function createSecureContext(context, pluginName, namespace, trustLevel, capabilities) {
|
|
35
|
+
const api = {
|
|
36
|
+
emitMetadata(patch) {
|
|
37
|
+
if (!capabilities.includes("metadata.write.own")) {
|
|
38
|
+
console.warn(`[AUDIT] Denied metadata.write.own for plugin "${pluginName}"`);
|
|
39
|
+
throw new Error(`Plugin "${pluginName}" lacks "metadata.write.own" capability`);
|
|
40
|
+
}
|
|
41
|
+
context.metadata[namespace] = {
|
|
42
|
+
...context.metadata[namespace] ?? {},
|
|
43
|
+
...patch
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
emitProcessing(patch) {
|
|
47
|
+
if (!capabilities.includes("processing.write.own")) {
|
|
48
|
+
console.warn(`[AUDIT] Denied processing.write.own for plugin "${pluginName}"`);
|
|
49
|
+
throw new Error(`Plugin "${pluginName}" lacks "processing.write.own" capability`);
|
|
50
|
+
}
|
|
51
|
+
context.processing[namespace] = {
|
|
52
|
+
...context.processing[namespace] ?? {},
|
|
53
|
+
...patch
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
proposeTrusted(patch) {
|
|
57
|
+
if (trustLevel !== "trusted" || !capabilities.includes("trusted.propose")) {
|
|
58
|
+
console.warn(
|
|
59
|
+
`[AUDIT] Security Violation: Unauthorized trusted.propose from plugin "${pluginName}"`
|
|
60
|
+
);
|
|
61
|
+
throw new Error(`Plugin "${pluginName}" is not authorized to propose trusted metadata`);
|
|
62
|
+
}
|
|
63
|
+
const secureContext = context;
|
|
64
|
+
const verified = secureContext._verifiedSources ?? /* @__PURE__ */ new Set();
|
|
65
|
+
if (!verified.has("file:content") && (patch.file || patch.checksums || patch.media)) {
|
|
66
|
+
console.warn(
|
|
67
|
+
`[AUDIT] Provenance Failure: Plugin "${pluginName}" attempted to propose trusted metadata without independent verification (no file content read).`
|
|
68
|
+
);
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Plugin "${pluginName}" must verify file content before proposing trusted metadata`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
core.TrustedMetadataSchema.parse(patch);
|
|
74
|
+
const auditLog = secureContext._auditLog ??= [];
|
|
75
|
+
auditLog.push({
|
|
76
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
77
|
+
plugin: pluginName,
|
|
78
|
+
action: "proposeTrusted",
|
|
79
|
+
patch: JSON.parse(JSON.stringify(patch))
|
|
80
|
+
// Snapshot the patch
|
|
81
|
+
});
|
|
82
|
+
console.info(
|
|
83
|
+
`[AUDIT] Accepted trusted proposal from plugin "${pluginName}" for file "${context.file.key}"`
|
|
84
|
+
);
|
|
85
|
+
context.trusted = {
|
|
86
|
+
...context.trusted,
|
|
87
|
+
...patch,
|
|
88
|
+
file: { ...context.trusted.file, ...patch.file },
|
|
89
|
+
checksums: { ...context.trusted.checksums, ...patch.checksums },
|
|
90
|
+
media: { ...context.trusted.media, ...patch.media }
|
|
91
|
+
};
|
|
92
|
+
if (patch.file) {
|
|
93
|
+
if (patch.file.mimeType != null) context.file.mimeType = patch.file.mimeType;
|
|
94
|
+
if (patch.file.size != null) context.file.size = patch.file.size;
|
|
95
|
+
if (patch.file.originalName != null) context.file.originalName = patch.file.originalName;
|
|
96
|
+
if (patch.file.extension != null) context.file.extension = patch.file.extension;
|
|
97
|
+
}
|
|
98
|
+
if (patch.checksums) {
|
|
99
|
+
context.file.checksums = { ...context.file.checksums, ...patch.checksums };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const proxy = new Proxy(context, {
|
|
104
|
+
get(target, prop, receiver) {
|
|
105
|
+
const value = Reflect.get(target, prop, receiver);
|
|
106
|
+
if (prop === "metadata" || prop === "processing" || prop === "trusted" || prop === "file") {
|
|
107
|
+
return new Proxy(value, {
|
|
108
|
+
set() {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Direct mutation of "context.${String(prop)}" is blocked. Use PluginApi instead. (Plugin: ${pluginName})`
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
deleteProperty() {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Direct deletion of "context.${String(prop)}" properties is blocked. (Plugin: ${pluginName})`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
},
|
|
122
|
+
set(target, prop) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Direct mutation of "context.${String(prop)}" is blocked. (Plugin: ${pluginName})`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return { proxy, api };
|
|
129
|
+
}
|
|
130
|
+
var JOB_QUEUE_NAME, LifecycleEngine;
|
|
131
|
+
var init_lifecycle_engine = __esm({
|
|
132
|
+
"src/core/lifecycle-engine.ts"() {
|
|
133
|
+
JOB_QUEUE_NAME = "better-media:background";
|
|
134
|
+
LifecycleEngine = class {
|
|
135
|
+
constructor(registry, jobAdapter) {
|
|
136
|
+
this.registry = registry;
|
|
137
|
+
this.jobAdapter = jobAdapter;
|
|
138
|
+
}
|
|
139
|
+
async trigger(hookName, context) {
|
|
140
|
+
const handlers = this.registry.get(hookName) ?? [];
|
|
141
|
+
const syncHandlers = handlers.filter((h) => h.mode === "sync");
|
|
142
|
+
const backgroundHandlers = handlers.filter((h) => h.mode === "background");
|
|
143
|
+
for (const { name, fn, manifest } of syncHandlers) {
|
|
144
|
+
const { proxy, api } = createSecureContext(
|
|
145
|
+
context,
|
|
146
|
+
name,
|
|
147
|
+
manifest.namespace,
|
|
148
|
+
manifest.trustLevel,
|
|
149
|
+
manifest.capabilities
|
|
150
|
+
);
|
|
151
|
+
const result = await fn(proxy, api);
|
|
152
|
+
if (result !== void 0 && typeof result === "object" && "valid" in result) {
|
|
153
|
+
if (result.valid === false) return result;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const { name, manifest } of backgroundHandlers) {
|
|
157
|
+
const payload = {
|
|
158
|
+
recordId: context.recordId,
|
|
159
|
+
metadata: JSON.parse(JSON.stringify(context.metadata)),
|
|
160
|
+
file: JSON.parse(JSON.stringify(context.file)),
|
|
161
|
+
storageLocation: JSON.parse(JSON.stringify(context.storageLocation)),
|
|
162
|
+
processing: JSON.parse(JSON.stringify(context.processing)),
|
|
163
|
+
hookName,
|
|
164
|
+
pluginName: name,
|
|
165
|
+
manifest: JSON.parse(JSON.stringify(manifest))
|
|
166
|
+
};
|
|
167
|
+
await this.jobAdapter.enqueue(JOB_QUEUE_NAME, payload);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// src/plugins/plugin-registry.ts
|
|
175
|
+
init_lifecycle_engine();
|
|
176
|
+
function createEmptyRegistry() {
|
|
177
|
+
const reg = /* @__PURE__ */ new Map();
|
|
178
|
+
for (const name of core.HOOK_NAMES) {
|
|
179
|
+
reg.set(name, []);
|
|
180
|
+
}
|
|
181
|
+
return reg;
|
|
182
|
+
}
|
|
183
|
+
function clearRegistry(registry) {
|
|
184
|
+
for (const [, list] of registry) {
|
|
185
|
+
list.length = 0;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function createHook(registry, hookName, manifest) {
|
|
189
|
+
return {
|
|
190
|
+
tap(name, fn, options) {
|
|
191
|
+
const requested = options?.mode ?? "sync";
|
|
192
|
+
const { effective, overridden } = core.resolveHookMode(hookName, requested);
|
|
193
|
+
if (overridden) {
|
|
194
|
+
console.warn(
|
|
195
|
+
`[better-media] Hook '${hookName}' does not support mode '${requested}'. Overriding to '${effective}'. Plugin: ${name}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const list = registry.get(hookName);
|
|
199
|
+
list.push({ name, fn, mode: effective, manifest });
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function createPluginRuntime(registry, plugin) {
|
|
204
|
+
const hooks = {};
|
|
205
|
+
for (const name of core.HOOK_NAMES) {
|
|
206
|
+
const baseTap = createHook(registry, name, plugin.runtimeManifest).tap;
|
|
207
|
+
hooks[name] = {
|
|
208
|
+
tap(handlerName, fn, options) {
|
|
209
|
+
const mode = options?.mode ?? (plugin.intensive ? "background" : "sync");
|
|
210
|
+
baseTap(handlerName, fn, { ...options, mode });
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
return { hooks };
|
|
215
|
+
}
|
|
216
|
+
var SYSTEM_MANIFEST = {
|
|
217
|
+
id: "better-media-system",
|
|
218
|
+
version: "1.0.0",
|
|
219
|
+
trustLevel: "trusted",
|
|
220
|
+
capabilities: ["file.read", "metadata.write.own", "processing.write.own", "trusted.propose"],
|
|
221
|
+
namespace: "system"
|
|
222
|
+
};
|
|
223
|
+
function createMediaRuntime(plugins, registry) {
|
|
224
|
+
const runtime = createPluginRuntime(registry, {
|
|
225
|
+
runtimeManifest: SYSTEM_MANIFEST
|
|
226
|
+
});
|
|
227
|
+
for (const plugin of plugins) {
|
|
228
|
+
const pluginRuntime = createPluginRuntime(registry, plugin);
|
|
229
|
+
if (plugin.apply) {
|
|
230
|
+
plugin.apply(pluginRuntime);
|
|
231
|
+
} else if (plugin.execute) {
|
|
232
|
+
const mode = plugin.executionMode ?? (plugin.intensive ? "background" : "sync");
|
|
233
|
+
const wrappedExecute = (ctx, _api) => plugin.execute(ctx);
|
|
234
|
+
pluginRuntime.hooks["process:run"].tap(plugin.name, wrappedExecute, { mode });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return runtime;
|
|
238
|
+
}
|
|
239
|
+
var DEFAULT_TRUSTED_POLICY = {
|
|
240
|
+
isAuthorized(id) {
|
|
241
|
+
const allowed = ["better-media-validation"];
|
|
242
|
+
return allowed.includes(id);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
function buildPluginRegistry(plugins, policy = DEFAULT_TRUSTED_POLICY) {
|
|
246
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
247
|
+
const seenNamespaces = /* @__PURE__ */ new Set();
|
|
248
|
+
for (const plugin of plugins) {
|
|
249
|
+
validatePlugin(plugin);
|
|
250
|
+
const { id, namespace, trustLevel } = plugin.runtimeManifest;
|
|
251
|
+
if (trustLevel === "trusted" && !policy.isAuthorized(id, namespace)) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Security Violation: Plugin "${plugin.name}" (${id}) is not authorized for "trusted" status.`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (seenIds.has(id)) {
|
|
257
|
+
throw new Error(`Duplicate plugin ID detected: ${id} (Plugin: ${plugin.name})`);
|
|
258
|
+
}
|
|
259
|
+
if (seenNamespaces.has(namespace)) {
|
|
260
|
+
throw new Error(`Duplicate namespace detected: ${namespace} (Plugin: ${plugin.name})`);
|
|
261
|
+
}
|
|
262
|
+
seenIds.add(id);
|
|
263
|
+
seenNamespaces.add(namespace);
|
|
264
|
+
}
|
|
265
|
+
const registry = createEmptyRegistry();
|
|
266
|
+
const runtime = createMediaRuntime(plugins, registry);
|
|
267
|
+
return { registry, runtime };
|
|
268
|
+
}
|
|
269
|
+
function validatePlugin(plugin) {
|
|
270
|
+
if (!plugin.name || typeof plugin.name !== "string") {
|
|
271
|
+
throw new Error("Plugin must have a non-empty string name");
|
|
272
|
+
}
|
|
273
|
+
if (!plugin.apply && !plugin.execute) {
|
|
274
|
+
throw new Error(`Plugin "${plugin.name}" must define apply() or execute()`);
|
|
275
|
+
}
|
|
276
|
+
if (!plugin.runtimeManifest) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Plugin "${plugin.name}" is missing runtimeManifest. V1 Secure Model requires manifests.`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const { id, version, trustLevel, capabilities, namespace } = plugin.runtimeManifest;
|
|
282
|
+
if (!id || !version || !namespace) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`Plugin "${plugin.name}" manifest is missing required fields (id, version, namespace)`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (!["untrusted", "trusted"].includes(trustLevel)) {
|
|
288
|
+
throw new Error(`Plugin "${plugin.name}" has invalid trustLevel: ${trustLevel}`);
|
|
289
|
+
}
|
|
290
|
+
if (!Array.isArray(capabilities)) {
|
|
291
|
+
throw new Error(`Plugin "${plugin.name}" capabilities must be an array`);
|
|
292
|
+
}
|
|
293
|
+
if (trustLevel === "untrusted" && capabilities.includes("trusted.propose")) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`Untrusted plugin "${plugin.name}" cannot request "trusted.propose" capability`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
var PluginRegistry = class {
|
|
300
|
+
plugins = [];
|
|
301
|
+
registry = createEmptyRegistry();
|
|
302
|
+
engine;
|
|
303
|
+
constructor(jobAdapter, initialPlugins = []) {
|
|
304
|
+
this.engine = new LifecycleEngine(this.registry, jobAdapter);
|
|
305
|
+
for (const plugin of initialPlugins) {
|
|
306
|
+
this.register(plugin);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** Register a plugin and rebuild the hook map */
|
|
310
|
+
register(plugin) {
|
|
311
|
+
validatePlugin(plugin);
|
|
312
|
+
this.plugins.push(plugin);
|
|
313
|
+
clearRegistry(this.registry);
|
|
314
|
+
createMediaRuntime(this.plugins, this.registry);
|
|
315
|
+
}
|
|
316
|
+
/** Get all registered plugins (shallow copy) */
|
|
317
|
+
getPlugins() {
|
|
318
|
+
return [...this.plugins];
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Execute all handlers for a hook. Runs sync handlers in series.
|
|
322
|
+
* Enqueues background handlers via JobAdapter.
|
|
323
|
+
* @returns ValidationResult if validation phase aborts (valid: false)
|
|
324
|
+
*/
|
|
325
|
+
async executeHook(hookName, context) {
|
|
326
|
+
return this.engine.trigger(hookName, context);
|
|
327
|
+
}
|
|
328
|
+
/** Access the internal hook registry (for framework wiring) */
|
|
329
|
+
getRegistry() {
|
|
330
|
+
return this.registry;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
function hasBackgroundHandlers(registry) {
|
|
334
|
+
for (const handlers of registry.values()) {
|
|
335
|
+
if (handlers.some((h) => h.mode === "background")) return true;
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/index.ts
|
|
341
|
+
init_lifecycle_engine();
|
|
342
|
+
async function loadTrustedFromDb(database, recordId) {
|
|
343
|
+
const record = await database.findOne({
|
|
344
|
+
model: "media",
|
|
345
|
+
where: [{ field: "id", value: recordId }]
|
|
346
|
+
});
|
|
347
|
+
if (!record || typeof record !== "object") return null;
|
|
348
|
+
const rawTrusted = {
|
|
349
|
+
file: {
|
|
350
|
+
mimeType: record.mimeType ?? void 0,
|
|
351
|
+
size: record.size ?? void 0,
|
|
352
|
+
originalName: record.filename ?? void 0
|
|
353
|
+
},
|
|
354
|
+
checksums: {
|
|
355
|
+
sha256: record.checksum ?? void 0
|
|
356
|
+
},
|
|
357
|
+
media: {
|
|
358
|
+
width: record.width ?? void 0,
|
|
359
|
+
height: record.height ?? void 0,
|
|
360
|
+
duration: record.duration ?? void 0
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
const result = core.TrustedMetadataSchema.safeParse(rawTrusted);
|
|
364
|
+
if (!result.success) {
|
|
365
|
+
console.error(`[QUARANTINE] Invalid TrustedMetadata mapped from media record "${recordId}"!`);
|
|
366
|
+
console.error(`[QUARANTINE] Reason: ${JSON.stringify(result.error.format())}`);
|
|
367
|
+
console.error(`[QUARANTINE] Data: ${JSON.stringify(rawTrusted)}`);
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
const validated = result.data;
|
|
371
|
+
return validated.file || validated.checksums || validated.media ? validated : null;
|
|
372
|
+
}
|
|
373
|
+
async function saveTrustedToDb(database, recordId, fileKey, trusted, initialArgs) {
|
|
374
|
+
const updatePayload = {};
|
|
375
|
+
if (trusted.file?.mimeType !== void 0) updatePayload.mimeType = trusted.file.mimeType;
|
|
376
|
+
else if (initialArgs?.mimeType !== void 0) updatePayload.mimeType = initialArgs.mimeType;
|
|
377
|
+
if (trusted.file?.size !== void 0) updatePayload.size = trusted.file.size;
|
|
378
|
+
else if (initialArgs?.size !== void 0) updatePayload.size = initialArgs.size;
|
|
379
|
+
if (trusted.file?.originalName !== void 0) updatePayload.filename = trusted.file.originalName;
|
|
380
|
+
else if (initialArgs?.filename !== void 0) updatePayload.filename = initialArgs.filename;
|
|
381
|
+
if (trusted.checksums?.sha256 !== void 0) updatePayload.checksum = trusted.checksums.sha256;
|
|
382
|
+
if (trusted.media?.width !== void 0) updatePayload.width = trusted.media.width;
|
|
383
|
+
if (trusted.media?.height !== void 0) updatePayload.height = trusted.media.height;
|
|
384
|
+
if (trusted.media?.duration !== void 0) updatePayload.duration = trusted.media.duration;
|
|
385
|
+
if (initialArgs?.context !== void 0) updatePayload.context = initialArgs.context;
|
|
386
|
+
const existing = await database.findOne({
|
|
387
|
+
model: "media",
|
|
388
|
+
where: [{ field: "id", value: recordId }]
|
|
389
|
+
});
|
|
390
|
+
updatePayload.storageKey = fileKey;
|
|
391
|
+
if (existing) {
|
|
392
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
393
|
+
await database.update({
|
|
394
|
+
model: "media",
|
|
395
|
+
where: [{ field: "id", value: recordId }],
|
|
396
|
+
update: updatePayload
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
updatePayload.id = recordId;
|
|
401
|
+
updatePayload.status = "PROCESSING";
|
|
402
|
+
updatePayload.createdAt = /* @__PURE__ */ new Date();
|
|
403
|
+
await database.create({
|
|
404
|
+
model: "media",
|
|
405
|
+
data: updatePayload
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function streamToTempFile(stream$1, fileKey) {
|
|
410
|
+
const ext = path__default.default.extname(fileKey) || ".bin";
|
|
411
|
+
const tmpPath = path__default.default.join(os__default.default.tmpdir(), `better-media-${crypto.randomUUID()}${ext}`);
|
|
412
|
+
const nodeStream = stream$1 instanceof stream.Readable ? stream$1 : stream.Readable.fromWeb(stream$1);
|
|
413
|
+
const writeStream = fs.createWriteStream(tmpPath);
|
|
414
|
+
await new Promise((resolve, reject) => {
|
|
415
|
+
nodeStream.pipe(writeStream);
|
|
416
|
+
nodeStream.on("error", reject);
|
|
417
|
+
writeStream.on("error", reject);
|
|
418
|
+
writeStream.on("finish", resolve);
|
|
419
|
+
});
|
|
420
|
+
return tmpPath;
|
|
421
|
+
}
|
|
422
|
+
async function loadFileIntoContext(context, fileHandling) {
|
|
423
|
+
const { file, storage } = context;
|
|
424
|
+
const fileKey = file.key;
|
|
425
|
+
const maxBufferBytes = fileHandling.maxBufferBytes;
|
|
426
|
+
const storageWithExtras = storage;
|
|
427
|
+
if (!context.utilities) context.utilities = {};
|
|
428
|
+
const fileContent = {};
|
|
429
|
+
let useStream = false;
|
|
430
|
+
if (maxBufferBytes != null && typeof storageWithExtras.getSize === "function" && typeof storageWithExtras.getStream === "function") {
|
|
431
|
+
const size = await storageWithExtras.getSize(fileKey);
|
|
432
|
+
if (size != null && size > maxBufferBytes) {
|
|
433
|
+
useStream = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (useStream && typeof storageWithExtras.getStream === "function") {
|
|
437
|
+
const stream = await storageWithExtras.getStream(fileKey);
|
|
438
|
+
if (stream != null) {
|
|
439
|
+
const tmpPath = await streamToTempFile(stream, fileKey);
|
|
440
|
+
fileContent.tempPath = tmpPath;
|
|
441
|
+
} else {
|
|
442
|
+
const buffer = await storage.get(fileKey);
|
|
443
|
+
if (buffer != null) fileContent.buffer = buffer;
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
const buffer = await storage.get(fileKey);
|
|
447
|
+
if (buffer != null) fileContent.buffer = buffer;
|
|
448
|
+
}
|
|
449
|
+
context.utilities.fileContent = fileContent;
|
|
450
|
+
if (fileContent.buffer != null || fileContent.tempPath != null) {
|
|
451
|
+
core.markFileContentVerified(context);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
async function cleanupTempFile(context) {
|
|
455
|
+
const tmpPath = context.utilities?.fileContent?.tempPath;
|
|
456
|
+
if (tmpPath) {
|
|
457
|
+
await fs2__default.default.unlink(tmpPath).catch(() => {
|
|
458
|
+
});
|
|
459
|
+
if (context.utilities?.fileContent) {
|
|
460
|
+
context.utilities.fileContent.tempPath = void 0;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/core/pipeline-executor.ts
|
|
466
|
+
function buildFileInfo(fileKey, metadata) {
|
|
467
|
+
const mime = metadata.contentType ?? metadata.mimeType ?? metadata["content-type"];
|
|
468
|
+
const size = typeof metadata.size === "number" ? metadata.size : void 0;
|
|
469
|
+
const originalName = metadata.originalName ?? metadata.originalname;
|
|
470
|
+
const ext = originalName ? path__default.default.extname(originalName).toLowerCase() : path__default.default.extname(fileKey).toLowerCase();
|
|
471
|
+
return {
|
|
472
|
+
key: fileKey,
|
|
473
|
+
size,
|
|
474
|
+
mimeType: typeof mime === "string" ? mime : void 0,
|
|
475
|
+
originalName: typeof originalName === "string" ? originalName : void 0,
|
|
476
|
+
extension: ext || void 0,
|
|
477
|
+
checksums: void 0
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
function buildStorageLocation(fileKey) {
|
|
481
|
+
return {
|
|
482
|
+
key: fileKey,
|
|
483
|
+
bucket: void 0,
|
|
484
|
+
region: void 0,
|
|
485
|
+
url: void 0
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function syncTrustedToFile(context) {
|
|
489
|
+
const { trusted, file } = context;
|
|
490
|
+
if (trusted.file?.mimeType != null) file.mimeType = trusted.file.mimeType;
|
|
491
|
+
if (trusted.file?.size != null) file.size = trusted.file.size;
|
|
492
|
+
if (trusted.file?.originalName != null) file.originalName = trusted.file.originalName;
|
|
493
|
+
if (trusted.checksums) file.checksums = { ...file.checksums, ...trusted.checksums };
|
|
494
|
+
}
|
|
495
|
+
var ValidationError = class extends Error {
|
|
496
|
+
constructor(recordId, fileKey, result) {
|
|
497
|
+
super(result.message ?? "Validation failed");
|
|
498
|
+
this.recordId = recordId;
|
|
499
|
+
this.fileKey = fileKey;
|
|
500
|
+
this.result = result;
|
|
501
|
+
this.name = "ValidationError";
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
var PipelineExecutor = class {
|
|
505
|
+
constructor(engine, storage, database, jobs, fileHandling = {}) {
|
|
506
|
+
this.engine = engine;
|
|
507
|
+
this.storage = storage;
|
|
508
|
+
this.database = database;
|
|
509
|
+
this.jobs = jobs;
|
|
510
|
+
this.fileHandling = fileHandling;
|
|
511
|
+
}
|
|
512
|
+
async run(recordId, fileKey, metadata = {}, appContext = {}) {
|
|
513
|
+
const meta = { ...metadata };
|
|
514
|
+
const trustedFromDb = await loadTrustedFromDb(this.database, recordId);
|
|
515
|
+
const context = {
|
|
516
|
+
recordId,
|
|
517
|
+
file: buildFileInfo(fileKey, meta),
|
|
518
|
+
storageLocation: buildStorageLocation(fileKey),
|
|
519
|
+
processing: {},
|
|
520
|
+
metadata: { ...meta, ...appContext },
|
|
521
|
+
// Merge for plugins to read backwards-compatibly
|
|
522
|
+
trusted: trustedFromDb ?? {},
|
|
523
|
+
utilities: {},
|
|
524
|
+
storage: this.storage,
|
|
525
|
+
database: this.database,
|
|
526
|
+
jobs: this.jobs
|
|
527
|
+
};
|
|
528
|
+
if (trustedFromDb) {
|
|
529
|
+
syncTrustedToFile(context);
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
await loadFileIntoContext(context, this.fileHandling);
|
|
533
|
+
await saveTrustedToDb(this.database, recordId, fileKey, context.trusted, {
|
|
534
|
+
filename: context.file.originalName,
|
|
535
|
+
mimeType: context.file.mimeType,
|
|
536
|
+
size: context.file.size,
|
|
537
|
+
context: appContext
|
|
538
|
+
});
|
|
539
|
+
for (const phase of core.HOOK_NAMES) {
|
|
540
|
+
const result = await this.engine.trigger(phase, context);
|
|
541
|
+
if (result !== void 0 && typeof result === "object" && result.valid === false) {
|
|
542
|
+
throw new ValidationError(recordId, fileKey, result);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
await saveTrustedToDb(this.database, recordId, fileKey, context.trusted, {
|
|
546
|
+
filename: context.file.originalName,
|
|
547
|
+
mimeType: context.file.mimeType,
|
|
548
|
+
size: context.file.size,
|
|
549
|
+
context: appContext
|
|
550
|
+
});
|
|
551
|
+
} finally {
|
|
552
|
+
await cleanupTempFile(context);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
function syncTrustedToFile2(context) {
|
|
557
|
+
const { trusted, file } = context;
|
|
558
|
+
if (trusted.file?.mimeType != null) file.mimeType = trusted.file.mimeType;
|
|
559
|
+
if (trusted.file?.size != null) file.size = trusted.file.size;
|
|
560
|
+
if (trusted.file?.originalName != null) file.originalName = trusted.file.originalName;
|
|
561
|
+
if (trusted.checksums) file.checksums = { ...file.checksums, ...trusted.checksums };
|
|
562
|
+
}
|
|
563
|
+
async function runBackgroundJob(payload, registry, storage, database, jobs, fileHandling = {}) {
|
|
564
|
+
const {
|
|
565
|
+
recordId: payloadRecordId,
|
|
566
|
+
metadata = {},
|
|
567
|
+
file: payloadFile,
|
|
568
|
+
storageLocation: payloadStorage,
|
|
569
|
+
processing: payloadProcessing,
|
|
570
|
+
hookName,
|
|
571
|
+
pluginName
|
|
572
|
+
} = payload;
|
|
573
|
+
const meta = { ...metadata };
|
|
574
|
+
const legacyKey = payload.fileKey;
|
|
575
|
+
if (!payloadFile && !legacyKey) {
|
|
576
|
+
throw new Error("Background job payload must include file or fileKey");
|
|
577
|
+
}
|
|
578
|
+
const file = payloadFile ?? (legacyKey ? {
|
|
579
|
+
key: legacyKey,
|
|
580
|
+
size: typeof meta.size === "number" ? meta.size : void 0,
|
|
581
|
+
mimeType: typeof (meta.contentType ?? meta.mimeType ?? meta["content-type"]) === "string" ? meta.contentType ?? meta.mimeType ?? meta["content-type"] : void 0,
|
|
582
|
+
originalName: typeof (meta.originalName ?? meta.originalname) === "string" ? meta.originalName ?? meta.originalname : void 0,
|
|
583
|
+
extension: path__default.default.extname(legacyKey).toLowerCase() || void 0
|
|
584
|
+
} : { key: "" });
|
|
585
|
+
const recordId = payloadRecordId ?? file.key ?? "unknown";
|
|
586
|
+
const storageLocation = payloadStorage ?? { key: file.key };
|
|
587
|
+
const processing = payloadProcessing ?? {};
|
|
588
|
+
const trustedFromDb = await loadTrustedFromDb(database, recordId);
|
|
589
|
+
const context = {
|
|
590
|
+
recordId,
|
|
591
|
+
file,
|
|
592
|
+
storageLocation,
|
|
593
|
+
processing,
|
|
594
|
+
metadata: meta,
|
|
595
|
+
trusted: trustedFromDb ?? {},
|
|
596
|
+
utilities: {},
|
|
597
|
+
storage,
|
|
598
|
+
database,
|
|
599
|
+
jobs
|
|
600
|
+
};
|
|
601
|
+
if (trustedFromDb) {
|
|
602
|
+
syncTrustedToFile2(context);
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
await loadFileIntoContext(context, fileHandling);
|
|
606
|
+
const handlers = registry.get(hookName) ?? [];
|
|
607
|
+
const handler = handlers.find((h) => h.name === pluginName);
|
|
608
|
+
if (!handler) {
|
|
609
|
+
throw new Error(`Handler not found: ${hookName}/${pluginName}`);
|
|
610
|
+
}
|
|
611
|
+
const manifest = handler.manifest;
|
|
612
|
+
const { createSecureContext: createSecureContext2 } = await Promise.resolve().then(() => (init_lifecycle_engine(), lifecycle_engine_exports));
|
|
613
|
+
const { proxy, api } = createSecureContext2(
|
|
614
|
+
context,
|
|
615
|
+
pluginName,
|
|
616
|
+
manifest.namespace,
|
|
617
|
+
manifest.trustLevel,
|
|
618
|
+
manifest.capabilities
|
|
619
|
+
);
|
|
620
|
+
await handler.fn(proxy, api);
|
|
621
|
+
if (context.trusted.file ?? context.trusted.checksums) {
|
|
622
|
+
await saveTrustedToDb(database, recordId, file.key, context.trusted);
|
|
623
|
+
}
|
|
624
|
+
} finally {
|
|
625
|
+
await cleanupTempFile(context);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function quote(name) {
|
|
629
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
630
|
+
}
|
|
631
|
+
function rowToAppKeys(row) {
|
|
632
|
+
const mapped = {};
|
|
633
|
+
for (const [key, value] of Object.entries(row)) {
|
|
634
|
+
mapped[core.toCamelCase(key)] = value;
|
|
635
|
+
}
|
|
636
|
+
return mapped;
|
|
637
|
+
}
|
|
638
|
+
function buildWhere(where, startAt = 1) {
|
|
639
|
+
if (!where?.length) return { sql: "", values: [] };
|
|
640
|
+
const parts = [];
|
|
641
|
+
const values = [];
|
|
642
|
+
let idx = startAt;
|
|
643
|
+
for (let i = 0; i < where.length; i++) {
|
|
644
|
+
const condition = where[i];
|
|
645
|
+
if (!condition) continue;
|
|
646
|
+
const connector = i > 0 ? where[i - 1]?.connector ?? "AND" : "AND";
|
|
647
|
+
const field = quote(core.toDbFieldName(condition.field));
|
|
648
|
+
const op = condition.operator ?? "=";
|
|
649
|
+
if (i > 0) parts.push(connector);
|
|
650
|
+
if (op === "contains" || op === "starts_with" || op === "ends_with") {
|
|
651
|
+
const raw = String(condition.value ?? "");
|
|
652
|
+
const value = op === "contains" ? `%${raw}%` : op === "starts_with" ? `${raw}%` : `%${raw}`;
|
|
653
|
+
parts.push(`${field} LIKE $${idx}`);
|
|
654
|
+
values.push(value);
|
|
655
|
+
idx += 1;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (op === "in" || op === "not_in") {
|
|
659
|
+
const list = Array.isArray(condition.value) ? condition.value : [condition.value];
|
|
660
|
+
if (!list.length) {
|
|
661
|
+
parts.push(op === "in" ? "FALSE" : "TRUE");
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
const placeholders = list.map(() => `$${idx++}`).join(", ");
|
|
665
|
+
parts.push(`${field} ${op === "in" ? "IN" : "NOT IN"} (${placeholders})`);
|
|
666
|
+
values.push(...list);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (condition.value === null && (op === "=" || op === "!=")) {
|
|
670
|
+
parts.push(`${field} ${op === "=" ? "IS" : "IS NOT"} NULL`);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const sqlOp = op === "!=" ? "<>" : op;
|
|
674
|
+
parts.push(`${field} ${sqlOp} $${idx}`);
|
|
675
|
+
values.push(condition.value);
|
|
676
|
+
idx += 1;
|
|
677
|
+
}
|
|
678
|
+
if (!parts.length) return { sql: "", values: [] };
|
|
679
|
+
return { sql: ` WHERE ${parts.join(" ")}`, values };
|
|
680
|
+
}
|
|
681
|
+
var PostgresDatabaseAdapter = class _PostgresDatabaseAdapter {
|
|
682
|
+
constructor(db) {
|
|
683
|
+
this.db = db;
|
|
684
|
+
}
|
|
685
|
+
id = "postgres";
|
|
686
|
+
modelFields(model) {
|
|
687
|
+
return core.schema[model]?.fields ?? {};
|
|
688
|
+
}
|
|
689
|
+
async create(options) {
|
|
690
|
+
const fields = this.modelFields(options.model);
|
|
691
|
+
const serialized = core.serializeData(fields, options.data);
|
|
692
|
+
const keys = Object.keys(serialized);
|
|
693
|
+
const columns = keys.map((k) => quote(core.toDbFieldName(k))).join(", ");
|
|
694
|
+
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
|
|
695
|
+
const values = keys.map((k) => serialized[k]);
|
|
696
|
+
const result = await this.db.query(
|
|
697
|
+
`INSERT INTO ${quote(options.model)} (${columns}) VALUES (${placeholders}) RETURNING *`,
|
|
698
|
+
values
|
|
699
|
+
);
|
|
700
|
+
return core.deserializeData(fields, rowToAppKeys(result.rows[0] ?? serialized));
|
|
701
|
+
}
|
|
702
|
+
async findOne(options) {
|
|
703
|
+
const rows = await this.findMany({ ...options, limit: 1 });
|
|
704
|
+
return rows[0] ?? null;
|
|
705
|
+
}
|
|
706
|
+
async findMany(options) {
|
|
707
|
+
const fields = this.modelFields(options.model);
|
|
708
|
+
const select = options.select && options.select.length > 0 ? options.select.map((f) => `${quote(core.toDbFieldName(f))} AS ${quote(f)}`).join(", ") : "*";
|
|
709
|
+
let query = `SELECT ${select} FROM ${quote(options.model)}`;
|
|
710
|
+
const where = buildWhere(options.where);
|
|
711
|
+
query += where.sql;
|
|
712
|
+
const values = [...where.values];
|
|
713
|
+
if (options.sortBy) {
|
|
714
|
+
query += ` ORDER BY ${quote(core.toDbFieldName(options.sortBy.field))} ${options.sortBy.direction.toUpperCase()}`;
|
|
715
|
+
}
|
|
716
|
+
if (typeof options.limit === "number") {
|
|
717
|
+
query += ` LIMIT $${values.length + 1}`;
|
|
718
|
+
values.push(options.limit);
|
|
719
|
+
}
|
|
720
|
+
if (typeof options.offset === "number") {
|
|
721
|
+
query += ` OFFSET $${values.length + 1}`;
|
|
722
|
+
values.push(options.offset);
|
|
723
|
+
}
|
|
724
|
+
const result = await this.db.query(query, values);
|
|
725
|
+
return result.rows.map((r) => core.deserializeData(fields, rowToAppKeys(r)));
|
|
726
|
+
}
|
|
727
|
+
async update(options) {
|
|
728
|
+
const updated = await this.updateMany(options);
|
|
729
|
+
if (!updated) return null;
|
|
730
|
+
return this.findOne({ model: options.model, where: options.where });
|
|
731
|
+
}
|
|
732
|
+
async updateMany(options) {
|
|
733
|
+
const fields = this.modelFields(options.model);
|
|
734
|
+
const serialized = core.serializeData(fields, options.update);
|
|
735
|
+
const entries = Object.entries(serialized);
|
|
736
|
+
if (!entries.length) return 0;
|
|
737
|
+
const setSql = entries.map(([key], i) => `${quote(core.toDbFieldName(key))} = $${i + 1}`).join(", ");
|
|
738
|
+
const setValues = entries.map(([, value]) => value);
|
|
739
|
+
const where = buildWhere(options.where, setValues.length + 1);
|
|
740
|
+
const query = `UPDATE ${quote(options.model)} SET ${setSql}${where.sql}`;
|
|
741
|
+
const result = await this.db.query(query, [...setValues, ...where.values]);
|
|
742
|
+
return Number(result.rowCount ?? 0);
|
|
743
|
+
}
|
|
744
|
+
async delete(options) {
|
|
745
|
+
await this.deleteMany(options);
|
|
746
|
+
}
|
|
747
|
+
async deleteMany(options) {
|
|
748
|
+
const where = buildWhere(options.where);
|
|
749
|
+
const query = `DELETE FROM ${quote(options.model)}${where.sql}`;
|
|
750
|
+
const result = await this.db.query(query, where.values);
|
|
751
|
+
return Number(result.rowCount ?? 0);
|
|
752
|
+
}
|
|
753
|
+
async count(options) {
|
|
754
|
+
const where = buildWhere(options.where);
|
|
755
|
+
const result = await this.db.query(
|
|
756
|
+
`SELECT COUNT(*)::int AS c FROM ${quote(options.model)}${where.sql}`,
|
|
757
|
+
where.values
|
|
758
|
+
);
|
|
759
|
+
return Number(result.rows[0]?.c ?? 0);
|
|
760
|
+
}
|
|
761
|
+
async raw(query, params) {
|
|
762
|
+
const result = await this.db.query(query, params);
|
|
763
|
+
return result.rows;
|
|
764
|
+
}
|
|
765
|
+
async transaction(callback) {
|
|
766
|
+
const pool = this.db;
|
|
767
|
+
if (typeof pool.connect !== "function") {
|
|
768
|
+
throw new Error("[better-media] The provided Postgres client does not support transactions.");
|
|
769
|
+
}
|
|
770
|
+
const client = await pool.connect();
|
|
771
|
+
try {
|
|
772
|
+
await client.query("BEGIN");
|
|
773
|
+
const trxAdapter = new _PostgresDatabaseAdapter(client);
|
|
774
|
+
const result = await callback(trxAdapter);
|
|
775
|
+
await client.query("COMMIT");
|
|
776
|
+
return result;
|
|
777
|
+
} catch (error) {
|
|
778
|
+
await client.query("ROLLBACK");
|
|
779
|
+
throw error;
|
|
780
|
+
} finally {
|
|
781
|
+
client.release?.();
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
__getDialect() {
|
|
785
|
+
return "postgres";
|
|
786
|
+
}
|
|
787
|
+
async __getMetadata() {
|
|
788
|
+
const result = await this.db.query(
|
|
789
|
+
`SELECT table_name AS "tableName", column_name AS "columnName", data_type AS "dataType", is_nullable AS "isNullable"
|
|
790
|
+
FROM information_schema.columns
|
|
791
|
+
WHERE table_schema = current_schema()
|
|
792
|
+
ORDER BY table_name, ordinal_position`
|
|
793
|
+
);
|
|
794
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
795
|
+
for (const row of result.rows) {
|
|
796
|
+
const tableName = String(row.tableName);
|
|
797
|
+
if (!grouped.has(tableName)) grouped.set(tableName, { name: tableName, columns: [] });
|
|
798
|
+
grouped.get(tableName).columns.push({
|
|
799
|
+
name: core.toCamelCase(String(row.columnName)),
|
|
800
|
+
dataType: String(row.dataType),
|
|
801
|
+
isNullable: String(row.isNullable).toUpperCase() === "YES"
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
return [...grouped.values()];
|
|
805
|
+
}
|
|
806
|
+
async __executeMigration(operation) {
|
|
807
|
+
if (operation.type === "createTable") {
|
|
808
|
+
const columns = Object.entries(operation.definition.fields).map(([name, field]) => {
|
|
809
|
+
const parts = [quote(core.toDbFieldName(name)), core.getColumnType(field, "postgres")];
|
|
810
|
+
if (field.primaryKey) parts.push("PRIMARY KEY");
|
|
811
|
+
if (field.required) parts.push("NOT NULL");
|
|
812
|
+
if (field.unique) parts.push("UNIQUE");
|
|
813
|
+
if (field.references) {
|
|
814
|
+
parts.push(
|
|
815
|
+
`REFERENCES ${quote(field.references.model)}(${quote(core.toDbFieldName(field.references.field))}) ON DELETE ${String(
|
|
816
|
+
field.references.onDelete ?? "CASCADE"
|
|
817
|
+
).toUpperCase()}`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
return parts.join(" ");
|
|
821
|
+
}).join(", ");
|
|
822
|
+
await this.db.query(`CREATE TABLE IF NOT EXISTS ${quote(operation.table)} (${columns})`);
|
|
823
|
+
for (const index of operation.definition.indexes ?? []) {
|
|
824
|
+
const indexName = `idx_${operation.table}_${index.fields.map((f) => core.toDbFieldName(f)).join("_")}`;
|
|
825
|
+
await this.db.query(
|
|
826
|
+
`CREATE ${index.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${quote(indexName)} ON ${quote(
|
|
827
|
+
operation.table
|
|
828
|
+
)} (${index.fields.map((f) => quote(core.toDbFieldName(f))).join(", ")})`
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (operation.type === "addColumn") {
|
|
834
|
+
const field = operation.definition;
|
|
835
|
+
const parts = [
|
|
836
|
+
quote(core.toDbFieldName(operation.field)),
|
|
837
|
+
core.getColumnType(operation.definition, "postgres")
|
|
838
|
+
];
|
|
839
|
+
if (field.required) parts.push("NOT NULL");
|
|
840
|
+
if (field.unique) parts.push("UNIQUE");
|
|
841
|
+
if (field.references) {
|
|
842
|
+
parts.push(
|
|
843
|
+
`REFERENCES ${quote(field.references.model)}(${quote(core.toDbFieldName(field.references.field))}) ON DELETE ${String(
|
|
844
|
+
field.references.onDelete ?? "CASCADE"
|
|
845
|
+
).toUpperCase()}`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
await this.db.query(
|
|
849
|
+
`ALTER TABLE ${quote(operation.table)} ADD COLUMN IF NOT EXISTS ${parts.join(" ")}`
|
|
850
|
+
);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (operation.type === "createIndex") {
|
|
854
|
+
await this.db.query(
|
|
855
|
+
`CREATE ${operation.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${quote(
|
|
856
|
+
operation.name
|
|
857
|
+
)} ON ${quote(operation.table)} (${operation.fields.map((f) => quote(core.toDbFieldName(f))).join(", ")})`
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
function postgresDatabase(pool) {
|
|
863
|
+
return new PostgresDatabaseAdapter(pool);
|
|
864
|
+
}
|
|
865
|
+
function toDatabaseAdapter(database) {
|
|
866
|
+
if (core.isPgPoolLike(database) && typeof database.create !== "function") {
|
|
867
|
+
return postgresDatabase(database);
|
|
868
|
+
}
|
|
869
|
+
return database;
|
|
870
|
+
}
|
|
871
|
+
function createNoopJobAdapter() {
|
|
872
|
+
return {
|
|
873
|
+
async enqueue(_name, _payload) {
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
async function normalizeInput(input, fileHandling) {
|
|
878
|
+
const { file, metadata = {}, deleteAfterUpload = true } = input;
|
|
879
|
+
const maxBufferBytes = fileHandling.maxBufferBytes;
|
|
880
|
+
let data;
|
|
881
|
+
let shouldDeleteSource = false;
|
|
882
|
+
let sourcePath;
|
|
883
|
+
if ("buffer" in file && file.buffer) {
|
|
884
|
+
data = file.buffer;
|
|
885
|
+
} else if ("path" in file && file.path) {
|
|
886
|
+
if (maxBufferBytes != null) {
|
|
887
|
+
const stat = await fs2__default.default.stat(file.path);
|
|
888
|
+
if (stat.size > maxBufferBytes) {
|
|
889
|
+
throw new Error(
|
|
890
|
+
`File at "${file.path}" is ${stat.size} bytes, which exceeds the configured maxBufferBytes limit of ${maxBufferBytes}. Use a storage adapter that supports streaming uploads, or increase maxBufferBytes.`
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
data = await fs2__default.default.readFile(file.path);
|
|
895
|
+
if (deleteAfterUpload) {
|
|
896
|
+
shouldDeleteSource = true;
|
|
897
|
+
sourcePath = file.path;
|
|
898
|
+
}
|
|
899
|
+
} else if ("stream" in file && file.stream) {
|
|
900
|
+
const chunks = [];
|
|
901
|
+
let totalBytes = 0;
|
|
902
|
+
for await (const chunk of file.stream) {
|
|
903
|
+
const buf = Buffer.from(chunk);
|
|
904
|
+
totalBytes += buf.length;
|
|
905
|
+
if (maxBufferBytes != null && totalBytes > maxBufferBytes) {
|
|
906
|
+
throw new Error(
|
|
907
|
+
`Stream exceeded the configured maxBufferBytes limit of ${maxBufferBytes}. Use a storage adapter that supports streaming uploads, or increase maxBufferBytes.`
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
chunks.push(buf);
|
|
911
|
+
}
|
|
912
|
+
data = Buffer.concat(chunks);
|
|
913
|
+
} else if ("url" in file && file.url) {
|
|
914
|
+
if (file.mode === "reference") {
|
|
915
|
+
throw new Error("URL reference mode is not fully implemented yet.");
|
|
916
|
+
}
|
|
917
|
+
const response = await fetch(file.url);
|
|
918
|
+
if (!response.ok) throw new Error(`Failed to fetch URL: ${response.statusText}`);
|
|
919
|
+
data = Buffer.from(await response.arrayBuffer());
|
|
920
|
+
} else {
|
|
921
|
+
throw new Error("Invalid MediaFileInput. Must provide buffer, stream, path, or url.");
|
|
922
|
+
}
|
|
923
|
+
return { data, metadata, shouldDeleteSource, sourcePath };
|
|
924
|
+
}
|
|
925
|
+
function createBetterMedia(config) {
|
|
926
|
+
const { storage, plugins, trustedPolicy } = config;
|
|
927
|
+
const database = toDatabaseAdapter(config.database);
|
|
928
|
+
const { registry } = buildPluginRegistry(plugins, trustedPolicy);
|
|
929
|
+
const fileHandling = config.fileHandling ?? {};
|
|
930
|
+
const jobAdapter = config.jobs ?? (hasBackgroundHandlers(registry) ? (() => {
|
|
931
|
+
const adapter = adapterJobs.memoryJobAdapter({
|
|
932
|
+
processor: (p) => runBackgroundJob(
|
|
933
|
+
p,
|
|
934
|
+
registry,
|
|
935
|
+
storage,
|
|
936
|
+
database,
|
|
937
|
+
adapter,
|
|
938
|
+
fileHandling
|
|
939
|
+
)
|
|
940
|
+
});
|
|
941
|
+
return adapter;
|
|
942
|
+
})() : createNoopJobAdapter());
|
|
943
|
+
const engine = new LifecycleEngine(registry, jobAdapter);
|
|
944
|
+
const executor = new PipelineExecutor(engine, storage, database, jobAdapter, fileHandling);
|
|
945
|
+
const runPipeline = (recordId, fileKey, metadata = {}, context = {}) => executor.run(recordId, fileKey, metadata, context);
|
|
946
|
+
return {
|
|
947
|
+
upload: {
|
|
948
|
+
async ingest(input) {
|
|
949
|
+
const normalized = await normalizeInput(input, fileHandling);
|
|
950
|
+
const recordId = crypto.randomUUID();
|
|
951
|
+
const finalKey = input.key ?? normalized.metadata.filename ?? recordId;
|
|
952
|
+
try {
|
|
953
|
+
await storage.put(finalKey, normalized.data);
|
|
954
|
+
await runPipeline(
|
|
955
|
+
recordId,
|
|
956
|
+
finalKey,
|
|
957
|
+
normalized.metadata,
|
|
958
|
+
normalized.metadata.context ?? {}
|
|
959
|
+
);
|
|
960
|
+
return {
|
|
961
|
+
id: recordId,
|
|
962
|
+
key: finalKey,
|
|
963
|
+
status: "processed",
|
|
964
|
+
metadata: normalized.metadata
|
|
965
|
+
};
|
|
966
|
+
} finally {
|
|
967
|
+
if (normalized.shouldDeleteSource && normalized.sourcePath) {
|
|
968
|
+
await fs2__default.default.unlink(normalized.sourcePath).catch((err) => console.warn("Cleanup failed:", err));
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
fromBuffer(buffer, input) {
|
|
973
|
+
return this.ingest({ file: { buffer }, ...input });
|
|
974
|
+
},
|
|
975
|
+
fromStream(stream, input) {
|
|
976
|
+
return this.ingest({ file: { stream }, ...input });
|
|
977
|
+
},
|
|
978
|
+
fromPath(path4, input) {
|
|
979
|
+
return this.ingest({ file: { path: path4 }, ...input });
|
|
980
|
+
},
|
|
981
|
+
fromUrl(url, input) {
|
|
982
|
+
return this.ingest({ file: { url, mode: input?.mode ?? "import" }, ...input });
|
|
983
|
+
},
|
|
984
|
+
async requestPresignedUpload(key, options) {
|
|
985
|
+
const fn = storage.createPresignedUpload;
|
|
986
|
+
if (typeof fn !== "function") {
|
|
987
|
+
throw new Error(
|
|
988
|
+
"Presigned upload not supported by this storage adapter. Use an S3/GCS adapter."
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
return fn.call(storage, key, options);
|
|
992
|
+
},
|
|
993
|
+
async complete(key, metadata = {}) {
|
|
994
|
+
const existing = await database.findOne({
|
|
995
|
+
model: "media",
|
|
996
|
+
where: [{ field: "storageKey", value: key }]
|
|
997
|
+
});
|
|
998
|
+
const recordId = existing?.id ?? crypto.randomUUID();
|
|
999
|
+
await runPipeline(
|
|
1000
|
+
recordId,
|
|
1001
|
+
key,
|
|
1002
|
+
metadata,
|
|
1003
|
+
metadata.context ?? {}
|
|
1004
|
+
);
|
|
1005
|
+
return {
|
|
1006
|
+
id: recordId,
|
|
1007
|
+
key,
|
|
1008
|
+
status: "processed",
|
|
1009
|
+
metadata
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
files: {
|
|
1014
|
+
get(id) {
|
|
1015
|
+
return database.findOne({ model: "media", where: [{ field: "id", value: id }] });
|
|
1016
|
+
},
|
|
1017
|
+
async delete(id) {
|
|
1018
|
+
const record = await this.get(id);
|
|
1019
|
+
const storageKey = record?.storageKey ?? id;
|
|
1020
|
+
await Promise.all([
|
|
1021
|
+
storage.delete(storageKey),
|
|
1022
|
+
database.delete({ model: "media", where: [{ field: "id", value: id }] })
|
|
1023
|
+
]);
|
|
1024
|
+
},
|
|
1025
|
+
async getUrl(id, options) {
|
|
1026
|
+
const record = await this.get(id);
|
|
1027
|
+
const storageKey = record?.storageKey ?? id;
|
|
1028
|
+
const fn = storage.getUrl;
|
|
1029
|
+
if (typeof fn !== "function") {
|
|
1030
|
+
throw new Error(
|
|
1031
|
+
"URL generation not supported by this storage adapter. Use an S3/GCS adapter."
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
return fn.call(storage, storageKey, options);
|
|
1035
|
+
},
|
|
1036
|
+
async reprocess(id, metadata = {}) {
|
|
1037
|
+
const record = await this.get(id);
|
|
1038
|
+
if (!record) throw new Error(`Media record not found: ${id}`);
|
|
1039
|
+
const storageKey = record.storageKey ?? id;
|
|
1040
|
+
return runPipeline(id, storageKey, metadata);
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
async runBackgroundJob(payload) {
|
|
1044
|
+
await runBackgroundJob(payload, registry, storage, database, jobAdapter, fileHandling);
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
Object.defineProperty(exports, "HOOK_NAMES", {
|
|
1050
|
+
enumerable: true,
|
|
1051
|
+
get: function () { return core.HOOK_NAMES; }
|
|
1052
|
+
});
|
|
1053
|
+
Object.defineProperty(exports, "MigrationPlanner", {
|
|
1054
|
+
enumerable: true,
|
|
1055
|
+
get: function () { return core.MigrationPlanner; }
|
|
1056
|
+
});
|
|
1057
|
+
Object.defineProperty(exports, "applyOperationsToMetadata", {
|
|
1058
|
+
enumerable: true,
|
|
1059
|
+
get: function () { return core.applyOperationsToMetadata; }
|
|
1060
|
+
});
|
|
1061
|
+
Object.defineProperty(exports, "compileMigrationOperationsSql", {
|
|
1062
|
+
enumerable: true,
|
|
1063
|
+
get: function () { return core.compileMigrationOperationsSql; }
|
|
1064
|
+
});
|
|
1065
|
+
Object.defineProperty(exports, "deserializeData", {
|
|
1066
|
+
enumerable: true,
|
|
1067
|
+
get: function () { return core.deserializeData; }
|
|
1068
|
+
});
|
|
1069
|
+
Object.defineProperty(exports, "getAdapter", {
|
|
1070
|
+
enumerable: true,
|
|
1071
|
+
get: function () { return core.getAdapter; }
|
|
1072
|
+
});
|
|
1073
|
+
Object.defineProperty(exports, "getColumnType", {
|
|
1074
|
+
enumerable: true,
|
|
1075
|
+
get: function () { return core.getColumnType; }
|
|
1076
|
+
});
|
|
1077
|
+
Object.defineProperty(exports, "getMigrations", {
|
|
1078
|
+
enumerable: true,
|
|
1079
|
+
get: function () { return core.getMigrations; }
|
|
1080
|
+
});
|
|
1081
|
+
Object.defineProperty(exports, "isPgPoolLike", {
|
|
1082
|
+
enumerable: true,
|
|
1083
|
+
get: function () { return core.isPgPoolLike; }
|
|
1084
|
+
});
|
|
1085
|
+
Object.defineProperty(exports, "runHooks", {
|
|
1086
|
+
enumerable: true,
|
|
1087
|
+
get: function () { return core.runHooks; }
|
|
1088
|
+
});
|
|
1089
|
+
Object.defineProperty(exports, "runMigrations", {
|
|
1090
|
+
enumerable: true,
|
|
1091
|
+
get: function () { return core.runMigrations; }
|
|
1092
|
+
});
|
|
1093
|
+
Object.defineProperty(exports, "schema", {
|
|
1094
|
+
enumerable: true,
|
|
1095
|
+
get: function () { return core.schema; }
|
|
1096
|
+
});
|
|
1097
|
+
Object.defineProperty(exports, "serializeData", {
|
|
1098
|
+
enumerable: true,
|
|
1099
|
+
get: function () { return core.serializeData; }
|
|
1100
|
+
});
|
|
1101
|
+
Object.defineProperty(exports, "toCamelCase", {
|
|
1102
|
+
enumerable: true,
|
|
1103
|
+
get: function () { return core.toCamelCase; }
|
|
1104
|
+
});
|
|
1105
|
+
Object.defineProperty(exports, "toDbFieldName", {
|
|
1106
|
+
enumerable: true,
|
|
1107
|
+
get: function () { return core.toDbFieldName; }
|
|
1108
|
+
});
|
|
1109
|
+
exports.PluginRegistry = PluginRegistry;
|
|
1110
|
+
exports.ValidationError = ValidationError;
|
|
1111
|
+
exports.buildPluginRegistry = buildPluginRegistry;
|
|
1112
|
+
exports.createBetterMedia = createBetterMedia;
|
|
1113
|
+
exports.hasBackgroundHandlers = hasBackgroundHandlers;
|
|
1114
|
+
exports.postgresDatabase = postgresDatabase;
|
|
1115
|
+
exports.toDatabaseAdapter = toDatabaseAdapter;
|
|
1116
|
+
exports.validatePlugin = validatePlugin;
|
|
1117
|
+
//# sourceMappingURL=index.js.map
|
|
1118
|
+
//# sourceMappingURL=index.js.map
|