@protobase/server 0.13.0 → 0.13.3

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.
@@ -1,6 +1,6 @@
1
1
  import { relatedErrors } from "./view-related.js";
2
2
  import { cardPhotosErrors } from "./view-card-photos.js";
3
- import { mapErrors } from "./view-map.js";
3
+ import { mapDisplayErrors, mapErrors } from "./view-map.js";
4
4
  import { addressErrors, addressPlaceholders, appHrefError, displayFields, fieldRefsIn, hrefPlaceholders } from "@protobase/schema";
5
5
  //#region src/check-views.ts
6
6
  const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
@@ -62,7 +62,7 @@ const widgetErrors = (view, model, fieldTypes) => Object.entries(view.fields).fl
62
62
  * field the resource does not have, labels or colours values its enum does not have, shows a field with a field type of
63
63
  * `fieldTypes` (each with the storage types it shows) that does not show it,
64
64
  * has a related section or a cards display's photos that do not fit the resources, a map that cannot show the
65
- * record's position, or a link to an address the app does not have.
65
+ * record's position or a map display that cannot find the records in its view, or a link to an address the app does not have.
66
66
  */
67
67
  const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
68
68
  const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
@@ -72,7 +72,7 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
72
72
  if (!model) return [];
73
73
  const related = view.layout.flatMap((item) => item.kind === "related" ? relatedErrors(item, view.resource, models).map((error) => `related "${item.title}": ${error}`) : []);
74
74
  const photos = (view.list?.displays ?? []).flatMap((display) => display.kind === "cards" && display.photos ? cardPhotosErrors(display.photos, view.resource, models).map((error) => `display "${display.name}" photos: ${error}`) : []);
75
- const maps = view.layout.flatMap((item) => item.kind === "map" ? mapErrors(item, model).map((error) => `map "${item.title}": ${error}`) : []);
75
+ const maps = [...view.layout.flatMap((item) => item.kind === "map" ? mapErrors(item, model).map((error) => `map "${item.title}": ${error}`) : []), ...(view.list?.displays ?? []).flatMap((display) => display.kind === "map" ? mapDisplayErrors(display, model).map((error) => `display "${display.name}": ${error}`) : [])];
76
76
  return [
77
77
  ...sensitiveErrors(view, model),
78
78
  ...unknownFieldErrors(view, model),
@@ -1,5 +1,5 @@
1
1
  import { badRequest } from "../problem.js";
2
- import { baseOf, parseUri } from "./uri.js";
2
+ import { baseOf, parseUri, variantPaths } from "./uri.js";
3
3
  import { tenantScope } from "../tenant.js";
4
4
  import { readTicket } from "./ticket.js";
5
5
  //#region src/files/attach.ts
@@ -52,8 +52,8 @@ const attachFiles = async (access, values, current) => {
52
52
  return result;
53
53
  };
54
54
  /**
55
- * Schedules the delete of every file a write took out of its row: a replaced or cleared value, or all of a hard-deleted
56
- * row's files, each due after its provider's retention. It runs inside the write transaction, before the commit: if
55
+ * Schedules the delete of every file a write took out of its row, with its variants: a replaced or cleared value, or all
56
+ * of a hard-deleted row's files, each due after its provider's retention. It runs inside the write transaction, before the commit: if
57
57
  * the write rolls back, the file is still referenced when the entry is due, and stays.
58
58
  */
59
59
  const scheduleRemovedFiles = async (access, operation, before, after) => {
@@ -68,10 +68,12 @@ const scheduleRemovedFiles = async (access, operation, before, after) => {
68
68
  if (typeof next === "string" && baseOf(next) === baseOf(old)) return [];
69
69
  const ref = parseUri(old);
70
70
  if (!ref || !files.providers[ref.provider]) return [];
71
+ const variants = Object.values(variantPaths(ref));
71
72
  return [{
72
73
  provider: ref.provider,
73
74
  path: ref.path,
74
- due: new Date(now + files.retention(ref.provider))
75
+ due: new Date(now + files.retention(ref.provider)),
76
+ ...variants.length > 0 && { variants }
75
77
  }];
76
78
  });
77
79
  if (removed.length > 0) await files.schedule.add(removed);
@@ -31,7 +31,8 @@ const referenced = async (db, columns, uris) => {
31
31
  /**
32
32
  * Handles every scheduled delete that is due: right before deleting, it checks that no declared file column holds the
33
33
  * file; one that is referenced again (a rolled-back write, a restore, a shared value) is kept and its entry dropped.
34
- * Uploads never attached are deleted here too, after their ticket expired. Running it twice at once is harmless.
34
+ * A file's variants go with it and stay with it. Uploads never attached are deleted here too, after their ticket
35
+ * expired. Running it twice at once is harmless.
35
36
  */
36
37
  const runCleanup = async ({ db, columns, providers, schedule, now = /* @__PURE__ */ new Date() }) => {
37
38
  const deleted = /* @__PURE__ */ new Set();
@@ -43,7 +44,9 @@ const runCleanup = async ({ db, columns, providers, schedule, now = /* @__PURE__
43
44
  const uri = `${entry.provider}:${entry.path}`;
44
45
  if (inUse.has(uri)) kept.add(uri);
45
46
  else if (!deleted.has(uri)) {
46
- await providers[entry.provider].store.delete(entry.path);
47
+ const { store } = providers[entry.provider];
48
+ for (const variant of entry.variants ?? []) await store.delete(variant);
49
+ await store.delete(entry.path);
47
50
  deleted.add(uri);
48
51
  }
49
52
  await schedule.done(entry);
@@ -1,10 +1,11 @@
1
1
  import { FileProvider } from "./store.js";
2
2
  //#region src/files/cleanup-schedule.d.ts
3
- /** A file to delete once `due`, unless a row references it again by then. */
3
+ /** A file to delete once `due`, unless a row references it again by then, with the paths of its image variants. */
4
4
  export type ScheduledDelete = {
5
5
  provider: string;
6
6
  path: string;
7
7
  due: Date;
8
+ variants?: string[];
8
9
  };
9
10
  export type CleanupEntry = ScheduledDelete & {
10
11
  id: string;
@@ -22,7 +23,8 @@ export type CleanupSchedule = {
22
23
  };
23
24
  /**
24
25
  * Scheduled deletes as a to-do folder in each provider's storage: one JSON object per delete in `.cleanup/`, named after
25
- * its due time (`.cleanup/2026-10-12T03-00-00Z-<uuid>.json`) and saying which provider and path to delete.
26
+ * its due time (`.cleanup/2026-10-12T03-00-00Z-<uuid>.json`) and saying which provider and path to delete, and the
27
+ * paths of the file's variants when it has them.
26
28
  */
27
29
  export declare const storageCleanupSchedule: (providers: Record<string, FileProvider>) => CleanupSchedule;
28
30
  //#endregion
@@ -2,10 +2,12 @@
2
2
  const cleanupFolder = ".cleanup/";
3
3
  const stamp = (date) => date.toISOString().slice(0, 19).replaceAll(":", "-") + "Z";
4
4
  const entryPattern = /^\.cleanup\/(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)-[0-9a-f-]{36}\.json$/;
5
- const isScheduledDelete = (value) => typeof value === "object" && value !== null && typeof value.provider === "string" && typeof value.path === "string" && typeof value.due === "string";
5
+ const isPathList = (value) => value === void 0 || Array.isArray(value) && value.every((path) => typeof path === "string");
6
+ const isScheduledDelete = (value) => typeof value === "object" && value !== null && typeof value.provider === "string" && typeof value.path === "string" && typeof value.due === "string" && isPathList(value.variants);
6
7
  /**
7
8
  * Scheduled deletes as a to-do folder in each provider's storage: one JSON object per delete in `.cleanup/`, named after
8
- * its due time (`.cleanup/2026-10-12T03-00-00Z-<uuid>.json`) and saying which provider and path to delete.
9
+ * its due time (`.cleanup/2026-10-12T03-00-00Z-<uuid>.json`) and saying which provider and path to delete, and the
10
+ * paths of the file's variants when it has them.
9
11
  */
10
12
  const storageCleanupSchedule = (providers) => {
11
13
  const storeOf = (provider) => {
@@ -15,11 +17,12 @@ const storageCleanupSchedule = (providers) => {
15
17
  };
16
18
  return {
17
19
  add: async (deletes) => {
18
- for (const { provider, path, due } of deletes) {
20
+ for (const { provider, path, due, variants } of deletes) {
19
21
  const body = new Blob([JSON.stringify({
20
22
  provider,
21
23
  path,
22
- due: due.toISOString()
24
+ due: due.toISOString(),
25
+ ...variants?.length && { variants }
23
26
  })]).stream();
24
27
  await storeOf(provider).put(`${cleanupFolder}${stamp(due)}-${crypto.randomUUID()}.json`, body);
25
28
  }
@@ -36,7 +39,8 @@ const storageCleanupSchedule = (providers) => {
36
39
  id: object.path,
37
40
  provider,
38
41
  path: content.path,
39
- due: new Date(content.due)
42
+ due: new Date(content.due),
43
+ ...content.variants?.length && { variants: content.variants }
40
44
  });
41
45
  }
42
46
  return entries;
@@ -2,7 +2,7 @@ import { HttpProblem, forbidden, notFound } from "../problem.js";
2
2
  import { decodeKeyText } from "../key.js";
3
3
  import { downloadExpiry, readDownloadUrl } from "./download-url.js";
4
4
  import { contentDisposition, downloadName, servedType } from "./media-types.js";
5
- import { baseOf, isFilePath, parseUri, tenantSegment } from "./uri.js";
5
+ import { isFilePath, parseUri, tenantSegment, variantPaths } from "./uri.js";
6
6
  import { fetchRecord } from "../records.js";
7
7
  import { readTransaction } from "../transactions.js";
8
8
  //#region src/files/download.ts
@@ -18,6 +18,11 @@ const rangeOf = (header, size) => {
18
18
  end
19
19
  } : false;
20
20
  };
21
+ const heldFile = (value, provider, path) => {
22
+ const ref = typeof value === "string" ? parseUri(value) : void 0;
23
+ if (!ref || ref.provider !== provider) return void 0;
24
+ return ref.path === path || Object.values(variantPaths(ref)).includes(path) ? ref : void 0;
25
+ };
21
26
  const grantedName = async (deps, provider, path, query) => {
22
27
  const read = await readDownloadUrl(deps.files.signer, provider, path, query);
23
28
  if (read.status === "invalid") throw forbidden("invalid-signature", "This file link is not valid");
@@ -33,10 +38,11 @@ const grantedName = async (deps, provider, path, query) => {
33
38
  fields
34
39
  }));
35
40
  const value = row?.record[field.name];
36
- if (typeof value !== "string" || baseOf(value) !== `${provider}:${path}`) throw gone();
41
+ const held = heldFile(value, provider, path);
42
+ if (!held) throw gone();
37
43
  if (tenantField && path.split("/")[0] !== await tenantSegment(row.record[tenantField.name])) throw gone();
38
44
  return {
39
- name: parseUri(value)?.name ?? path,
45
+ name: held.name,
40
46
  expires: grant.expires
41
47
  };
42
48
  };
@@ -0,0 +1,40 @@
1
+ import { ImageFormat, ImageVariant } from "@protobase/schema";
2
+ //#region src/files/images.d.ts
3
+ type SharpImage = {
4
+ resize(width: number, height: number, options: {
5
+ fit: 'inside';
6
+ }): SharpImage;
7
+ resize(options: {
8
+ width?: number;
9
+ height?: number;
10
+ fit: 'inside' | 'cover';
11
+ withoutEnlargement: boolean;
12
+ }): SharpImage;
13
+ ensureAlpha(): SharpImage;
14
+ raw(): SharpImage;
15
+ toFormat(format: ImageFormat, options: {
16
+ quality?: number;
17
+ }): SharpImage;
18
+ toBuffer(): Promise<Uint8Array>;
19
+ toBuffer(options: {
20
+ resolveWithObject: true;
21
+ }): Promise<{
22
+ data: Uint8Array;
23
+ info: {
24
+ width: number;
25
+ height: number;
26
+ };
27
+ }>;
28
+ };
29
+ /** sharp's default export, as `import sharp from 'sharp'` gives it: what `files.sharp` takes. */
30
+ export type Sharp = (input: Uint8Array, options: {
31
+ autoOrient: boolean;
32
+ }) => SharpImage;
33
+ /** An image's ThumbHash, base64: its shape and colours in about 25 bytes, drawn blurred while the image loads. */
34
+ export declare const thumbHash: (sharp: Sharp, input: Uint8Array) => Promise<string>;
35
+ /** An image resized and encoded as `variant` says, turned upright first; it is never enlarged and keeps no metadata. */
36
+ export declare const renderVariant: (sharp: Sharp, input: Uint8Array, variant: ImageVariant) => Promise<{
37
+ data: Uint8Array<ArrayBufferLike>;
38
+ extension: string;
39
+ }>;
40
+ //#endregion
@@ -0,0 +1,39 @@
1
+ import { rgbaToThumbHash } from "thumbhash";
2
+ //#region src/files/images.ts
3
+ /** The types sharp reads, as uploads are decided: an image of another type, such as HEIC or SVG, is kept as it is. */
4
+ const processableImages = /* @__PURE__ */ new Set([
5
+ "image/jpeg",
6
+ "image/png",
7
+ "image/webp",
8
+ "image/avif",
9
+ "image/gif",
10
+ "image/tiff"
11
+ ]);
12
+ /** The extension each variant format is stored with. */
13
+ const formatExtensions = {
14
+ webp: "webp",
15
+ avif: "avif",
16
+ jpeg: "jpg",
17
+ png: "png"
18
+ };
19
+ const thumbHashSide = 100;
20
+ /** An image's ThumbHash, base64: its shape and colours in about 25 bytes, drawn blurred while the image loads. */
21
+ const thumbHash = async (sharp, input) => {
22
+ const { data, info } = await sharp(input, { autoOrient: true }).resize(thumbHashSide, thumbHashSide, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
23
+ return btoa(String.fromCharCode(...rgbaToThumbHash(info.width, info.height, data)));
24
+ };
25
+ /** An image resized and encoded as `variant` says, turned upright first; it is never enlarged and keeps no metadata. */
26
+ const renderVariant = async (sharp, input, variant) => {
27
+ const image = sharp(input, { autoOrient: true });
28
+ return {
29
+ data: await (variant.width === void 0 && variant.height === void 0 ? image : image.resize({
30
+ width: variant.width,
31
+ height: variant.height,
32
+ fit: variant.fit ?? "inside",
33
+ withoutEnlargement: true
34
+ })).toFormat(variant.format, variant.quality === void 0 ? {} : { quality: variant.quality }).toBuffer(),
35
+ extension: formatExtensions[variant.format]
36
+ };
37
+ };
38
+ //#endregion
39
+ export { formatExtensions, processableImages, renderVariant, thumbHash };
@@ -1,3 +1,4 @@
1
+ import { Sharp } from "./images.js";
1
2
  import { FileProvider } from "./store.js";
2
3
  //#region src/files/options.d.ts
3
4
  /** `files` in `protobase.config.ts`. Environment variables override it (see `resolveProviders`). */
@@ -11,5 +12,10 @@ export type FilesOptions = {
11
12
  retention?: number | string;
12
13
  /** Signs upload tickets and download URLs; default `PROTOBASE_FILES_SECRET`, then `BETTER_AUTH_SECRET`. */
13
14
  secret?: string;
15
+ /**
16
+ * The image library that makes the variants and placeholders of image fields: `import sharp from 'sharp'` in the
17
+ * config, so a built bundle carries it with its native add-on.
18
+ */
19
+ sharp?: Sharp;
14
20
  };
15
21
  //#endregion
@@ -1,13 +1,25 @@
1
1
  import "../types.js";
2
2
  import "../request-access.js";
3
3
  //#region src/files/present.d.ts
4
- /** A file field as responses carry it; `missing` when the value does not parse or names a provider the config lacks. */
4
+ /** An image variant of a file: its link and type, and the box the field resizes it into, as configured now. */
5
+ export type FileVariantObject = {
6
+ url: string;
7
+ type: string;
8
+ width?: number;
9
+ height?: number;
10
+ };
11
+ /**
12
+ * A file field as responses carry it; `missing` when the value does not parse or names a provider the config lacks.
13
+ * `placeholder` is the ThumbHash of its placeholder field, when the row holds one.
14
+ */
5
15
  export type FileObject = {
6
16
  uri: string;
7
17
  name: string;
8
18
  type: string;
9
19
  size?: number;
10
20
  url?: string;
21
+ placeholder?: string;
22
+ variants?: Record<string, FileVariantObject>;
11
23
  } | {
12
24
  uri: string;
13
25
  missing: true;
@@ -1,45 +1,61 @@
1
1
  import { downloadExpiry, signedDownloadUrl } from "./download-url.js";
2
2
  import { servedType } from "./media-types.js";
3
- import { parseUri } from "./uri.js";
3
+ import { parseUri, variantPaths } from "./uri.js";
4
4
  import { encodeKey } from "@protobase/schema";
5
5
  //#region src/files/present.ts
6
+ const linker = async (files, access, row, field, ref, expires) => {
7
+ const provider = files.providers[ref.provider];
8
+ if (provider.public) return async (path) => `${provider.publicUrl ?? `${files.path}/${ref.provider}`}/${path}`;
9
+ const key = access.full.primaryKey.map((name) => row[name]);
10
+ if (key.some((part) => typeof part !== "string" && typeof part !== "number")) return void 0;
11
+ const keyText = encodeKey(key);
12
+ return (path) => signedDownloadUrl(files.signer, files.path, {
13
+ provider: ref.provider,
14
+ path,
15
+ resource: access.entry.name,
16
+ key: keyText,
17
+ field,
18
+ expires
19
+ });
20
+ };
6
21
  const fileObject = async (files, access, row, field, value, expires) => {
7
22
  const ref = parseUri(value);
8
- const provider = ref && files.providers[ref.provider];
9
- if (!ref || !provider) return {
23
+ if (!ref || !files.providers[ref.provider]) return {
10
24
  uri: value,
11
25
  missing: true
12
26
  };
27
+ const target = field.file?.placeholder;
28
+ const placeholder = target !== void 0 && access.model.fields[target] ? row[target] : void 0;
13
29
  const described = {
14
30
  uri: value,
15
31
  name: ref.name,
16
32
  type: servedType(ref.path),
17
- ...ref.size !== void 0 && { size: ref.size }
33
+ ...ref.size !== void 0 && { size: ref.size },
34
+ ...typeof placeholder === "string" && { placeholder }
18
35
  };
19
- if (provider.public) return {
20
- ...described,
21
- url: `${provider.publicUrl ?? `${files.path}/${ref.provider}`}/${ref.path}`
22
- };
23
- const key = access.full.primaryKey.map((name) => row[name]);
24
- if (key.some((part) => typeof part !== "string" && typeof part !== "number")) return described;
25
- const keyText = encodeKey(key);
26
- const url = await signedDownloadUrl(files.signer, files.path, {
27
- provider: ref.provider,
28
- path: ref.path,
29
- resource: access.entry.name,
30
- key: keyText,
31
- field,
32
- expires
33
- });
36
+ const link = await linker(files, access, row, field.name, ref, expires);
37
+ if (!link) return described;
38
+ const variants = {};
39
+ for (const [name, path] of Object.entries(variantPaths(ref))) {
40
+ const box = field.file?.variants?.[name];
41
+ variants[name] = {
42
+ url: await link(path),
43
+ type: servedType(path),
44
+ ...box?.width !== void 0 && { width: box.width },
45
+ ...box?.height !== void 0 && { height: box.height }
46
+ };
47
+ }
34
48
  return {
35
49
  ...described,
36
- url
50
+ url: await link(ref.path),
51
+ ...Object.keys(variants).length > 0 && { variants }
37
52
  };
38
53
  };
39
54
  /**
40
- * Records as responses carry them: each readable file field as `{ uri, name, type, size, url }`. The URL is minted here,
41
- * after the ETag, for a caller who may already see the row: private files get a signed link to this row's field, public
42
- * ones their permanent address. Stored values, hooks and ETags only ever see the URI.
55
+ * Records as responses carry them: each readable file field as `{ uri, name, type, size, url }`, with its `variants` and
56
+ * `placeholder` when it has them. The URLs are minted here, after the ETag, for a caller who may already see the row:
57
+ * private files get a signed link to this row's field, public ones their permanent address. Stored values, hooks and
58
+ * ETags only ever see the URI.
43
59
  */
44
60
  const presentFiles = async (access, rows) => {
45
61
  const files = access.deps.files;
@@ -50,7 +66,7 @@ const presentFiles = async (access, rows) => {
50
66
  const shown = { ...row };
51
67
  for (const field of fields) {
52
68
  const value = row[field.name];
53
- if (typeof value === "string") shown[field.name] = await fileObject(files, access, row, field.name, value, expires);
69
+ if (typeof value === "string") shown[field.name] = await fileObject(files, access, row, field, value, expires);
54
70
  }
55
71
  return shown;
56
72
  }));
@@ -1,3 +1,5 @@
1
+ import "./images.js";
2
+ import "./options.js";
1
3
  import "./cleanup-runner.js";
2
4
  import { ResourceModel } from "@protobase/schema";
3
5
  import { Db } from "@protobase/query";
@@ -5,15 +5,18 @@ import { createSigner } from "./signer.js";
5
5
  //#region src/files/runtime.ts
6
6
  /** How long an upload ticket stays usable, so a form left open still saves. */
7
7
  const ticketSeconds = 86400;
8
+ const imageFields = (models) => models.flatMap((model) => Object.values(model.fields).flatMap(({ name, file }) => file && (file.variants || file.original || file.placeholder !== void 0) ? [`${model.name}.${name}`] : []));
8
9
  /**
9
10
  * The runtime, when a resource has a file field or the config names providers. It fails at startup when a field's
10
- * provider is not configured or nothing signs uploads.
11
+ * provider is not configured, nothing signs uploads, or a field makes image variants or placeholders without sharp.
11
12
  */
12
13
  const createFilesRuntime = ({ options = {}, env, models, systemPath }) => {
13
14
  const used = [...new Set(models.flatMap((model) => Object.values(model.fields).flatMap((field) => field.file ? [field.file.provider] : [])))];
14
15
  if (used.length === 0 && !options.providers) return void 0;
15
16
  const secret = options.secret ?? env.PROTOBASE_FILES_SECRET ?? env.BETTER_AUTH_SECRET;
16
17
  if (!secret) throw new Error("File fields need a secret to sign uploads: set BETTER_AUTH_SECRET, PROTOBASE_FILES_SECRET or files.secret");
18
+ const images = imageFields(models);
19
+ if (images.length > 0 && !options.sharp) throw new Error(`${images.join(", ")} make image variants or placeholders, which need sharp: add it to the project (bun add sharp), then import sharp from 'sharp' in the config and pass files: { sharp }`);
17
20
  const providers = resolveProviders(options, env, used);
18
21
  const retention = retentionOf(options, env);
19
22
  return {
@@ -22,7 +25,8 @@ const createFilesRuntime = ({ options = {}, env, models, systemPath }) => {
22
25
  schedule: storageCleanupSchedule(providers),
23
26
  retention: (provider) => retention(providers[provider]),
24
27
  path: `${systemPath}/files`,
25
- columns: fileColumns(models)
28
+ columns: fileColumns(models),
29
+ ...options.sharp && { sharp: options.sharp }
26
30
  };
27
31
  };
28
32
  /** Runs the scheduled deletes that are due (see `runCleanup`). */
@@ -51,6 +51,7 @@ const readTicket = async (signer, value, scope, now) => {
51
51
  const known = /* @__PURE__ */ new Set([
52
52
  "name",
53
53
  "size",
54
+ "variants",
54
55
  "exp",
55
56
  "sig"
56
57
  ]);
@@ -58,10 +59,13 @@ const readTicket = async (signer, value, scope, now) => {
58
59
  ok: false,
59
60
  reason: "invalid"
60
61
  };
61
- const parsed = parseUri(value.slice(0, value.indexOf("?")) + `?${new URLSearchParams({
62
+ const variants = query.get("variants");
63
+ const fileQuery = new URLSearchParams({
62
64
  name: query.get("name") ?? "",
63
- size: query.get("size") ?? ""
64
- })}`);
65
+ size: query.get("size") ?? "",
66
+ ...variants !== null && { variants }
67
+ });
68
+ const parsed = parseUri(`${value.slice(0, value.indexOf("?"))}?${fileQuery}`);
65
69
  const expires = Number(query.get("exp"));
66
70
  if (!parsed || parsed.size === void 0 || !Number.isSafeInteger(expires)) return {
67
71
  ok: false,
@@ -0,0 +1,42 @@
1
+ import { variantPath } from "./uri.js";
2
+ import { formatExtensions, processableImages, renderVariant, thumbHash } from "./images.js";
3
+ //#region src/files/upload-images.ts
4
+ /** Whether an upload of `type` is an image this field processes: it declares variants, an original or a placeholder, and sharp reads the type. */
5
+ const processesImage = (file, type) => processableImages.has(type) && (file.variants !== void 0 || file.original !== void 0 || file.placeholder !== void 0);
6
+ /** The path the field's file is stored at: `<base>.<extension>`, with the original's format when an image replaces the upload. */
7
+ const storedPath = (file, base, decision) => file.original && processesImage(file, decision.type) ? `${base}.${formatExtensions[file.original.format]}` : `${base}.${decision.extension}`;
8
+ /** The extension of each variant an image of `type` gets, by name; none for other files. */
9
+ const plannedVariants = (file, type) => processesImage(file, type) ? Object.fromEntries(Object.entries(file.variants ?? {}).map(([name, variant]) => [name, formatExtensions[variant.format]])) : {};
10
+ const streamOf = (bytes) => new ReadableStream({ start(controller) {
11
+ controller.enqueue(bytes);
12
+ controller.close();
13
+ } });
14
+ /**
15
+ * Makes what an image field declares from the upload stored at `uploaded`: the image replacing it at `path` (the
16
+ * upload is then deleted), each variant beside `path`, and the ThumbHash. Answers the replacing image's bytes, if any.
17
+ */
18
+ const processImage = async ({ sharp, store, file, uploaded, path }) => {
19
+ const body = await store.read(uploaded);
20
+ if (!body) throw new Error(`The upload ${uploaded} is gone from its store`);
21
+ const input = new Uint8Array(await new Response(body).arrayBuffer());
22
+ let replaced;
23
+ if (file.original) {
24
+ const { data } = await renderVariant(sharp, input, file.original);
25
+ replaced = {
26
+ data,
27
+ size: (await store.put(path, streamOf(data))).size
28
+ };
29
+ if (path !== uploaded) await store.delete(uploaded);
30
+ }
31
+ for (const [name, variant] of Object.entries(file.variants ?? {})) {
32
+ const { data, extension } = await renderVariant(sharp, input, variant);
33
+ await store.put(variantPath(path, name, extension), streamOf(data));
34
+ }
35
+ const placeholder = file.placeholder === void 0 ? void 0 : await thumbHash(sharp, input);
36
+ return {
37
+ replaced,
38
+ placeholder
39
+ };
40
+ };
41
+ //#endregion
42
+ export { plannedVariants, processImage, processesImage, storedPath };
@@ -1,6 +1,6 @@
1
1
  import { HttpProblem, badRequest } from "../problem.js";
2
- import { accepts } from "./media-types.js";
3
- import { cleanName, tenantSegment } from "./uri.js";
2
+ import { accepts, servedType } from "./media-types.js";
3
+ import { cleanName, tenantSegment, variantPath } from "./uri.js";
4
4
  import { tenantScope } from "../tenant.js";
5
5
  import { requestAccess, requireOperation, writableFields } from "../request-access.js";
6
6
  import { issueTicket } from "./ticket.js";
@@ -8,6 +8,7 @@ import { ticketScope } from "./attach.js";
8
8
  import { ticketSeconds } from "./runtime.js";
9
9
  import { decideType } from "./detect.js";
10
10
  import { FileTooLarge, incomingFile, limitedStream, readHead, tooLarge } from "./upload-body.js";
11
+ import { plannedVariants, processImage, processesImage, storedPath } from "./upload-images.js";
11
12
  //#region src/files/upload.ts
12
13
  /** How many first bytes processors get as `head`: enough for image headers, EXIF included. */
13
14
  const headBytes = 262144;
@@ -49,8 +50,9 @@ const deriveValues = async (entry, field, input) => {
49
50
  /**
50
51
  * `POST /{resource}:upload?field=<field>`: streams one file to the field's provider and answers with a ticket for a
51
52
  * write. The type is decided from the first bytes before anything is stored, a refused type gets 415 and a file over
52
- * `maxSize` 413. The field's processors then compute its derived values, which the ticket carries signed. An upload no
53
- * write ever uses is deleted after the ticket expires.
53
+ * `maxSize` 413. An image field then makes its variants and placeholder, and replaces the upload when it declares an
54
+ * original; the field's processors compute its derived values, which the ticket carries signed with the variants. An
55
+ * upload no write ever uses is deleted after the ticket expires, variants included.
54
56
  */
55
57
  const uploadFile = async (deps, entry, session, request) => {
56
58
  const files = deps.files;
@@ -82,39 +84,74 @@ const uploadFile = async (deps, entry, session, request) => {
82
84
  });
83
85
  }
84
86
  const expires = Math.floor(Date.now() / 1e3) + ticketSeconds;
85
- const path = `${await tenantSegment(tenantScope(entry, session)?.tenantValue)}/${crypto.randomUUID()}.${decision.extension}`;
87
+ const base = `${await tenantSegment(tenantScope(entry, session)?.tenantValue)}/${crypto.randomUUID()}`;
88
+ const uploaded = `${base}.${decision.extension}`;
89
+ const path = storedPath(field.file, base, decision);
90
+ const variants = plannedVariants(field.file, decision.type);
91
+ const variantList = Object.entries(variants).map(([variant, extension]) => variantPath(path, variant, extension));
92
+ const due = /* @__PURE__ */ new Date((expires + 3600) * 1e3);
86
93
  await files.schedule.add([{
87
94
  provider: providerName,
88
95
  path,
89
- due: /* @__PURE__ */ new Date((expires + 3600) * 1e3)
90
- }]);
96
+ due,
97
+ ...variantList.length > 0 && { variants: variantList }
98
+ }, ...uploaded === path ? [] : [{
99
+ provider: providerName,
100
+ path: uploaded,
101
+ due
102
+ }]]);
91
103
  let size;
92
104
  try {
93
- size = (await provider.store.put(path, limitedStream(head, reader, maxSize))).size;
105
+ size = (await provider.store.put(uploaded, limitedStream(head, reader, maxSize))).size;
94
106
  } catch (error) {
95
107
  if (error instanceof FileTooLarge) throw tooLarge(maxSize);
96
108
  throw error;
97
109
  }
98
- const derived = await deriveValues(entry, field, {
99
- name,
110
+ let stored = {
100
111
  type: decision.type,
101
112
  size,
102
- head,
103
- store: provider.store,
104
- path
105
- });
113
+ head
114
+ };
115
+ let placeholder;
116
+ if (processesImage(field.file, decision.type)) {
117
+ if (!files.sharp) throw new Error(`${entry.name}.${field.name} makes image variants or placeholders, but no sharp is configured`);
118
+ const image = await processImage({
119
+ sharp: files.sharp,
120
+ store: provider.store,
121
+ file: field.file,
122
+ uploaded,
123
+ path
124
+ });
125
+ if (image.replaced) stored = {
126
+ type: servedType(path),
127
+ size: image.replaced.size,
128
+ head: image.replaced.data.subarray(0, headBytes)
129
+ };
130
+ placeholder = image.placeholder;
131
+ }
132
+ const target = field.file.placeholder;
133
+ const derived = {
134
+ ...await deriveValues(entry, field, {
135
+ name,
136
+ ...stored,
137
+ store: provider.store,
138
+ path
139
+ }),
140
+ ...target !== void 0 && { [target]: placeholder ?? null }
141
+ };
106
142
  const ref = {
107
143
  provider: providerName,
108
144
  path,
109
145
  name,
110
- size
146
+ size: stored.size,
147
+ ...variantList.length > 0 && { variants }
111
148
  };
112
149
  return {
113
150
  value: await issueTicket(files.signer, ref, derived, ticketScope(access, field.name), expires),
114
151
  file: {
115
152
  name,
116
- type: decision.type,
117
- size
153
+ type: stored.type,
154
+ size: stored.size
118
155
  },
119
156
  derived,
120
157
  ...decision.corrected && { corrected: decision.corrected }
@@ -0,0 +1,21 @@
1
+ //#region src/files/uri.d.ts
2
+ /**
3
+ * A stored file value: `{provider}:{path}?name=<name>&size=<bytes>`, such as `private:acme/2992db9c-….jpg?name=bike.png&size=48211`.
4
+ * It is an ordinary URI whose scheme is the provider's name; `public:images/test.jpg`, without a query, parses too.
5
+ * The part before `?` is the file's identity; the query is metadata about bytes that never change, and names the image
6
+ * variants stored beside it with their extension: `&variants=card.webp,thumb.avif`.
7
+ */
8
+ export type FileRef = {
9
+ provider: string;
10
+ path: string;
11
+ name: string;
12
+ size?: number;
13
+ variants?: Record<string, string>;
14
+ };
15
+ /**
16
+ * Where a file's variant is stored: beside it, its path followed by the variant's name and extension, such as
17
+ * `acme/2992db9c-….jpg.card.webp`. Every variant's address is built here.
18
+ */
19
+ export declare const variantPath: (path: string, name: string, extension: string) => string;
20
+ export declare const formatUri: (ref: FileRef) => string;
21
+ //#endregion
package/dist/files/uri.js CHANGED
@@ -6,10 +6,25 @@ const maxPath = 512;
6
6
  const isFilePath = (path) => path.length <= maxPath && path.split("/").every((part) => segment.test(part));
7
7
  /** The identity of a value or ticket: `{provider}:{path}`. */
8
8
  const baseOf = (value) => value.split("?", 1)[0];
9
+ const variantEntry = /^([a-z][a-z0-9_]{0,31})\.([a-z0-9]{1,8})$/;
10
+ /**
11
+ * Where a file's variant is stored: beside it, its path followed by the variant's name and extension, such as
12
+ * `acme/2992db9c-….jpg.card.webp`. Every variant's address is built here.
13
+ */
14
+ const variantPath = (path, name, extension) => `${path}.${name}.${extension}`;
15
+ /** The paths of a file's variants, by name. */
16
+ const variantPaths = (ref) => Object.fromEntries(Object.entries(ref.variants ?? {}).map(([name, extension]) => [name, variantPath(ref.path, name, extension)]));
17
+ const formatVariants = (variants) => Object.entries(variants).map(([name, extension]) => `${name}.${extension}`).join(",");
18
+ const parseVariants = (text) => {
19
+ const entries = text.split(",").map((entry) => variantEntry.exec(entry));
20
+ if (entries.some((entry) => !entry)) return void 0;
21
+ return Object.fromEntries(entries.map((entry) => [entry[1], entry[2]]));
22
+ };
9
23
  const formatUri = (ref) => {
10
24
  const query = new URLSearchParams({
11
25
  name: ref.name,
12
- ...ref.size !== void 0 && { size: String(ref.size) }
26
+ ...ref.size !== void 0 && { size: String(ref.size) },
27
+ ...ref.variants && Object.keys(ref.variants).length > 0 && { variants: formatVariants(ref.variants) }
13
28
  });
14
29
  return `${ref.provider}:${ref.path}?${query}`;
15
30
  };
@@ -21,11 +36,15 @@ const parseUri = (value) => {
21
36
  const query = new URLSearchParams(match[3] ?? "");
22
37
  const size = query.get("size");
23
38
  if (size !== null && !/^\d{1,15}$/.test(size)) return void 0;
39
+ const listed = query.get("variants");
40
+ const variants = listed === null ? void 0 : parseVariants(listed);
41
+ if (listed !== null && !variants) return void 0;
24
42
  return {
25
43
  provider: match[1],
26
44
  path: match[2],
27
45
  name: query.get("name") || lastSegment(match[2]),
28
- ...size !== null && { size: Number(size) }
46
+ ...size !== null && { size: Number(size) },
47
+ ...variants && { variants }
29
48
  };
30
49
  };
31
50
  const encoder = new TextEncoder();
@@ -44,4 +63,4 @@ const tenantSegment = async (tenant) => {
44
63
  return tenantText.test(text) ? text : `t${(await sha256Hex(text)).slice(0, 32)}`;
45
64
  };
46
65
  //#endregion
47
- export { baseOf, cleanName, formatUri, isFilePath, parseUri, tenantSegment };
66
+ export { baseOf, cleanName, formatUri, isFilePath, parseUri, tenantSegment, variantPath, variantPaths };
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ import { EnqueueOptions, JobContext, JobDefinition, JobOptions } from "./jobs/jo
13
13
  import { Admin, AuditEvent, AuditQueue, Authenticator, PipelineHook, ScanGuardMode, Session, SessionOrganization, TenantValue, User, ViewSource, WriteEvent, WriteOperation } from "./types.js";
14
14
  import { WebhooksOptions } from "./webhooks/table/options.js";
15
15
  import { EventData, RunContext, SubscriptionDefinition, SubscriptionEvent, SubscriptionOptions, subscription } from "./webhooks/subscription.js";
16
+ import { Sharp, renderVariant, thumbHash } from "./files/images.js";
16
17
  import { FileProvider, FileStore, StoredObject } from "./files/store.js";
17
18
  import { FilesOptions } from "./files/options.js";
18
19
  import { ErrorReportingOptions, ErrorReportingSettings, errorReportingSettings, errorReportingVariable } from "./error-reporting/error-reporting-settings.js";
@@ -71,11 +72,12 @@ import { FileLimits, limitedFileStore } from "./files/limits.js";
71
72
  import { LocalFilesOptions, localFileStore, localFiles } from "./files/local-files.js";
72
73
  import { S3Bucket, S3FilesOptions, S3ListPage, S3Object, s3FileStore, s3Files } from "./files/s3-files.js";
73
74
  import { FileCleanupInput, createFileCleanup } from "./files/file-cleanup.js";
74
- import { FileObject } from "./files/present.js";
75
+ import { FileObject, FileVariantObject } from "./files/present.js";
76
+ import { FileRef, formatUri, variantPath } from "./files/uri.js";
75
77
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
76
78
  import { defineJob, isJobDefinition, jobDefinitions } from "./jobs/define-job.js";
77
79
  import { FatalJobError } from "./jobs/run-job.js";
78
80
  import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/runtime.js";
79
81
  import { jobsDrainApp } from "./jobs/drain-route.js";
80
82
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
81
- export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileStore, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSource, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapOptions, type MemberInput, MissingCredentials, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type SignInPolicy, type SignInRule, type SignatureOptions, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, streamChatCompletion, subscription, unauthorized, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
83
+ export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CapturedRow, type ChatMessage, type ChatTextPart, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DrainResult, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSource, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapOptions, type MemberInput, MissingCredentials, ModelError, type ModelSettings, type NewOrganization, type NewUser, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type RecordKeyInput, type RecordPage, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.j
9
9
  import { requestApproval } from "./assistant/approval.js";
10
10
  import { withCacheBreakpoints } from "./assistant/cache-breakpoints.js";
11
11
  import { ModelError, streamChatCompletion } from "./assistant/chat-completions.js";
12
+ import { formatUri, variantPath } from "./files/uri.js";
12
13
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
13
14
  import { runTurn } from "./assistant/turn.js";
14
15
  import { consoleAuditQueue } from "./audit/console-queue.js";
@@ -38,6 +39,7 @@ import { mapStyleUrl, mapStyleVariable } from "./maps/map-settings.js";
38
39
  import { pluginOptions, pluginsFor, pluginsVariable } from "./plugins/plugin-settings.js";
39
40
  import { runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
40
41
  import { checkSourceNames, sourceOptions, sourcesFor, sourcesVariable } from "./sources/source-settings.js";
42
+ import { renderVariant, thumbHash } from "./files/images.js";
41
43
  import { createAdmin } from "./create-admin.js";
42
44
  import { configExports } from "./resource-source.js";
43
45
  import { defineConfig, mergeConfig } from "./project-config.js";
@@ -59,4 +61,4 @@ import { createFileCleanup } from "./files/file-cleanup.js";
59
61
  import { aspectRatio, fileSize, fileType, imageSize, processor } from "./files/processors.js";
60
62
  import { startJobs } from "./jobs/runtime.js";
61
63
  import { jobsDrainApp } from "./jobs/drain-route.js";
62
- export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, streamChatCompletion, subscription, unauthorized, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
64
+ export { FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, badRequest, betterAuthAuthenticator, changesMigration, checkAuthSchema, checkChanges, checkJobsSchema, checkSourceNames, checkWebhooksSchema, coalesce, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createFileCleanup, createOrganization, createUser, defaultSignInPolicy, defineConfig, defineFunction, defineJob, defineTool, deleteUser, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, hasUsers, imageSize, isJobDefinition, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, limitedFileStore, listOrganizations, listUsers, localFileStore, localFiles, mapStyleUrl, mapStyleVariable, maxTokenTtlSeconds, maxWidgetPropsBytes, mergeConfig, migrationCommand, newWebhookSecret, normalizeRoles, notFound, parseColumns, parseRoles, pluginOptions, pluginsFor, pluginsVariable, processor, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readWriteQueryTool, recordOf, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, setMemberRoles, setUserBanned, setUserRole, signInPolicyMethods, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, streamChatCompletion, subscription, thumbHash, unauthorized, variantPath, verifySignature, verifyStandardWebhook, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps };
@@ -0,0 +1,62 @@
1
+ import { checkedFilter } from "./filter-input.js";
2
+ import { readScope, requestAccess, requireOperation } from "./request-access.js";
3
+ import { readTransaction } from "./transactions.js";
4
+ import { parseParams } from "./params.js";
5
+ import { listRecords } from "./list-service.js";
6
+ import { checkMapPosition, countUpTo, mapBuckets, maxMapZoom } from "@protobase/query";
7
+ import { z } from "zod";
8
+ const mapParams = z.object({
9
+ latitude: z.string().min(1),
10
+ longitude: z.string().min(1),
11
+ zoom: z.coerce.number().int().min(0).max(maxMapZoom),
12
+ filter: z.string().optional(),
13
+ order_by: z.string().optional(),
14
+ fields: z.union([z.string(), z.array(z.string())]).optional()
15
+ });
16
+ /**
17
+ * The matching records for a map at `zoom`, the bounding box being part of the filter: the records themselves when
18
+ * few match or the map is close enough, as a list returns them, else buckets of a grid sized for the zoom. Both are
19
+ * checked and scoped like a list.
20
+ */
21
+ const mapRecords = async (deps, entry, session, input) => {
22
+ const params = parseParams(mapParams, input);
23
+ const access = await requestAccess(deps, entry, session);
24
+ requireOperation(access, "list");
25
+ const position = {
26
+ latitude: params.latitude,
27
+ longitude: params.longitude
28
+ };
29
+ checkMapPosition(access.model, position);
30
+ const filter = checkedFilter(access.model, params.filter);
31
+ const scope = readScope(access);
32
+ const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => countUpTo(trx, access.model, 201, filter, scope));
33
+ if (found <= 200 || params.zoom >= 16) {
34
+ const list = {
35
+ filter: params.filter,
36
+ order_by: params.order_by,
37
+ fields: params.fields,
38
+ page_size: 200,
39
+ ...found > 200 && { count: "exact" }
40
+ };
41
+ const { body, warning } = await listRecords(deps, entry, session, list);
42
+ return {
43
+ warning,
44
+ body: {
45
+ items: body.items,
46
+ buckets: [],
47
+ total_size: body.total_size ?? found
48
+ }
49
+ };
50
+ }
51
+ const result = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => mapBuckets(trx, access.model, position, params.zoom, filter, scope));
52
+ return {
53
+ warning: void 0,
54
+ body: {
55
+ items: [],
56
+ buckets: result.buckets,
57
+ total_size: result.total
58
+ }
59
+ };
60
+ };
61
+ //#endregion
62
+ export { mapRecords };
@@ -1,5 +1,6 @@
1
1
  import { writableFields } from "../request-access.js";
2
2
  import { pascal } from "./naming.js";
3
+ import { mapCellPixels, maxMapBuckets, maxMapZoom } from "@protobase/query";
3
4
  //#region src/openapi/resource-paths.ts
4
5
  const param = (name) => ({ $ref: `#/components/parameters/${name}` });
5
6
  const error = (name) => ({ $ref: `#/components/responses/${name}` });
@@ -63,12 +64,52 @@ const page = (name, seek = false) => json({
63
64
  }
64
65
  }
65
66
  });
67
+ const bounds = {
68
+ type: "object",
69
+ properties: {
70
+ south: { type: "number" },
71
+ west: { type: "number" },
72
+ north: { type: "number" },
73
+ east: { type: "number" }
74
+ }
75
+ };
76
+ const mapResult = (name) => json({
77
+ type: "object",
78
+ required: [
79
+ "items",
80
+ "buckets",
81
+ "total_size"
82
+ ],
83
+ properties: {
84
+ items: {
85
+ type: "array",
86
+ items: ref(name)
87
+ },
88
+ buckets: {
89
+ type: "array",
90
+ items: {
91
+ type: "object",
92
+ properties: {
93
+ count: { type: "integer" },
94
+ latitude: { type: "number" },
95
+ longitude: { type: "number" },
96
+ bounds
97
+ }
98
+ }
99
+ },
100
+ total_size: {
101
+ type: "integer",
102
+ description: "How many records match"
103
+ }
104
+ }
105
+ });
66
106
  const needs = {
67
107
  list: "list",
68
108
  search: "list",
69
109
  facets: "list",
70
110
  series: "list",
71
111
  histogram: "list",
112
+ map: "list",
72
113
  seek: "list",
73
114
  get: "read",
74
115
  reveal: "read",
@@ -386,6 +427,32 @@ const resourcePaths = (access) => {
386
427
  ...auth
387
428
  }
388
429
  } },
430
+ [`/${entry.name}:map`]: { get: {
431
+ operationId: `map_${entry.name}`,
432
+ tags: tag,
433
+ summary: `${entry.name} records on a map, or buckets of them`,
434
+ description: `Put the map's bounding box in the filter, such as \`lat >= 51.9 AND lat <= 52.1 AND lng >= 4.2 AND lng <= 4.5\`. When up to 200 records match, or from zoom 16 on, \`items\` holds them (at most 200) as a list returns them and \`buckets\` is empty; otherwise \`buckets\` counts them per cell of a Web Mercator grid about ${mapCellPixels} pixels across at the zoom (the fullest ${maxMapBuckets}) and \`items\` is empty. Records without a position are in no bucket.`,
435
+ parameters: [
436
+ query("latitude", "A filterable number field holding the latitude in decimal degrees", { type: "string" }, true),
437
+ query("longitude", "A filterable number field holding the longitude in decimal degrees", { type: "string" }, true),
438
+ query("zoom", "The map's zoom, as MapLibre counts it", {
439
+ type: "integer",
440
+ minimum: 0,
441
+ maximum: maxMapZoom
442
+ }, true),
443
+ param("filter"),
444
+ param("orderBy"),
445
+ param("fields")
446
+ ],
447
+ responses: {
448
+ 200: {
449
+ description: "The records or the buckets, and how many records match",
450
+ content: mapResult(name)
451
+ },
452
+ 400: error("BadRequest"),
453
+ ...auth
454
+ }
455
+ } },
389
456
  [`/${entry.name}:seek`]: { get: {
390
457
  operationId: `seek_${entry.name}`,
391
458
  tags: tag,
@@ -41,6 +41,24 @@ const fileSchema = {
41
41
  },
42
42
  size: { type: "integer" },
43
43
  url: { type: "string" },
44
+ placeholder: {
45
+ type: "string",
46
+ description: "The image's ThumbHash, base64, to draw while it loads"
47
+ },
48
+ variants: {
49
+ type: "object",
50
+ description: "Resized copies of the image by name, each with the box the field resizes it into",
51
+ additionalProperties: {
52
+ type: "object",
53
+ required: ["url", "type"],
54
+ properties: {
55
+ url: { type: "string" },
56
+ type: { type: "string" },
57
+ width: { type: "integer" },
58
+ height: { type: "integer" }
59
+ }
60
+ }
61
+ },
44
62
  missing: { type: "boolean" }
45
63
  }
46
64
  };
@@ -101,14 +101,16 @@ const mergedFiles = (named) => {
101
101
  label
102
102
  })));
103
103
  checkUnique("The file provider", providers);
104
- const { retention, secret } = lastSet(all.map(({ files }) => ({
104
+ const { retention, secret, sharp } = lastSet(all.map(({ files }) => ({
105
105
  retention: files.retention,
106
- secret: files.secret
106
+ secret: files.secret,
107
+ sharp: files.sharp
107
108
  })));
108
109
  return {
109
110
  ...providers.length > 0 && { providers: Object.fromEntries(providers.map(({ name, value }) => [name, value])) },
110
111
  ...retention !== void 0 && { retention },
111
- ...secret !== void 0 && { secret }
112
+ ...secret !== void 0 && { secret },
113
+ ...sharp !== void 0 && { sharp }
112
114
  };
113
115
  };
114
116
  const mergedJobs = (named) => {
@@ -1,6 +1,7 @@
1
1
  import { runWrite } from "../write-pipeline.js";
2
2
  import { listRecords } from "../list-service.js";
3
3
  import { facets, numericHistogram, timeSeries } from "../aggregate-service.js";
4
+ import { mapRecords } from "../map-service.js";
4
5
  import { uploadFile } from "../files/upload.js";
5
6
  import { returnPreference } from "./preferences.js";
6
7
  import { resolveTarget, unknownAction } from "./target.js";
@@ -23,6 +24,10 @@ const collectionRoutes = (deps) => {
23
24
  if (action === "facets") return c.json(await facets(deps, entry, session, query));
24
25
  if (action === "series") return c.json(await timeSeries(deps, entry, session, query));
25
26
  if (action === "histogram") return c.json(await numericHistogram(deps, entry, session, query));
27
+ if (action === "map") {
28
+ const { body, warning } = await mapRecords(deps, entry, session, query);
29
+ return c.json(body, 200, warningHeader(warning));
30
+ }
26
31
  if (action === "seek") {
27
32
  const { body, warning } = await listRecords(deps, entry, session, query, void 0, true);
28
33
  return c.json(body, 200, warningHeader(warning));
package/dist/view-map.js CHANGED
@@ -17,5 +17,13 @@ const mapErrors = (item, model) => [
17
17
  ...coordinateErrors(model, item.longitude),
18
18
  ...item.zoom === void 0 || Number.isFinite(item.zoom) && item.zoom >= 0 && item.zoom <= maxZoom ? [] : [`zoom ${item.zoom} is not between 0 and ${maxZoom}`]
19
19
  ];
20
+ const filterableErrors = (model, name) => Object.hasOwn(model.fields, name) && !model.fields[name].filterable ? [`"${name}" on ${model.name} is not filterable, and the map display filters by position`] : [];
21
+ /** What makes a map display unable to show the records: a field that is no number, or one it cannot filter by. */
22
+ const mapDisplayErrors = (display, model) => [
23
+ ...coordinateErrors(model, display.latitude),
24
+ ...coordinateErrors(model, display.longitude),
25
+ ...filterableErrors(model, display.latitude),
26
+ ...filterableErrors(model, display.longitude)
27
+ ];
20
28
  //#endregion
21
- export { mapErrors };
29
+ export { mapDisplayErrors, mapErrors };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.13.0",
3
+ "version": "0.13.3",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
- "@protobase/layout": "0.13.0",
34
- "@protobase/query": "0.13.0",
35
- "@protobase/schema": "0.13.0",
33
+ "@protobase/layout": "0.13.3",
34
+ "@protobase/query": "0.13.3",
35
+ "@protobase/schema": "0.13.3",
36
36
  "@scalar/hono-api-reference": "^0.12.10",
37
37
  "@sentry/core": "^11.7.0",
38
38
  "@simplewebauthn/server": "^13.3.1",
@@ -45,6 +45,15 @@
45
45
  "kysely": "^0.29.6",
46
46
  "nodemailer": "^10.0.16",
47
47
  "pg-boss": "12.37.1",
48
+ "thumbhash": "^0.1.1",
48
49
  "zod": "^4.6.5"
50
+ },
51
+ "peerDependencies": {
52
+ "sharp": "^0.35.5"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "sharp": {
56
+ "optional": true
57
+ }
49
58
  }
50
59
  }