@protobase/schema 0.7.0 → 0.8.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.
@@ -12,6 +12,10 @@ export type AccessContext = {
12
12
  user?: AccessUser;
13
13
  operation?: AccessAction;
14
14
  record?: unknown;
15
+ /** With organizations, the one the request works in; `.tenant` resources are scoped to it already. */
16
+ organization?: {
17
+ id: string | number;
18
+ };
15
19
  /** Set by the resolver; scoped capabilities (`.own`) need `model.owner`. */
16
20
  model?: ResourceModel;
17
21
  [key: string]: unknown;
package/dist/field.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { FieldType } from "./model.js";
2
2
  import { FieldAccess } from "./access/types.js";
3
+ import { FileOptions } from "./files/file-options.js";
3
4
  import { z } from "zod";
4
5
  //#region src/field.d.ts
5
6
  export type Constraint = 'min' | 'max' | 'regex';
@@ -29,6 +30,7 @@ export type FieldMeta = {
29
30
  re: RegExp;
30
31
  message: string;
31
32
  };
33
+ file?: FileOptions;
32
34
  };
33
35
  export type FieldTypeDef = {
34
36
  type: FieldType;
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ //#region src/fields/file.ts
3
+ /** A file column holds `{provider}:{path}?name=…&size=…` as text; writes send a ticket from `:upload` instead. */
4
+ const file = {
5
+ type: "file",
6
+ constraints: [],
7
+ build: () => z.string().min(1).max(4096)
8
+ };
9
+ //#endregion
10
+ export { file };
package/dist/fields.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Field } from "./field.js";
2
2
  import { RelationField } from "./relation-field.js";
3
+ import { FileField } from "./file-field.js";
3
4
  import { z } from "zod";
4
5
  //#region src/fields.d.ts
5
6
  export declare const f: {
@@ -17,6 +18,8 @@ export declare const f: {
17
18
  enum: <const T extends readonly string[]>(values: T) => Field<T[number]>;
18
19
  json: () => Field<z.core.util.JSONType>;
19
20
  relation: (resource: string) => RelationField<string | number>;
21
+ /** A file: its column holds `{provider}:{path}?name=…&size=…` as text, and responses carry `{ uri, name, type, size, url }`. */
22
+ file: () => FileField<string>;
20
23
  currency: () => Field<string>;
21
24
  country: () => Field<string>;
22
25
  };
package/dist/fields.js CHANGED
@@ -5,6 +5,7 @@ import { currency } from "./fields/currency.js";
5
5
  import { date } from "./fields/date.js";
6
6
  import { decimal } from "./fields/decimal.js";
7
7
  import { enumeration } from "./fields/enum.js";
8
+ import { file } from "./fields/file.js";
8
9
  import { integer } from "./fields/integer.js";
9
10
  import { json } from "./fields/json.js";
10
11
  import { relation } from "./fields/relation.js";
@@ -13,6 +14,8 @@ import { timestamp } from "./fields/timestamp.js";
13
14
  import { uuid } from "./fields/uuid.js";
14
15
  import { Field } from "./field.js";
15
16
  import { RelationField } from "./relation-field.js";
17
+ import { defaultMaxSize } from "./files/file-options.js";
18
+ import { FileField } from "./file-field.js";
16
19
  //#region src/fields.ts
17
20
  const baseMeta = (def) => ({
18
21
  type: def.type,
@@ -42,6 +45,15 @@ const f = {
42
45
  ...baseMeta(relation),
43
46
  relation: { resource }
44
47
  }),
48
+ /** A file: its column holds `{provider}:{path}?name=…&size=…` as text, and responses carry `{ uri, name, type, size, url }`. */
49
+ file: () => new FileField(file, {
50
+ ...baseMeta(file),
51
+ file: {
52
+ accept: [],
53
+ maxSize: defaultMaxSize,
54
+ provider: "private"
55
+ }
56
+ }),
45
57
  currency: () => make(currency),
46
58
  country: () => make(country)
47
59
  };
@@ -0,0 +1,27 @@
1
+ import { FieldAccess } from "./access/types.js";
2
+ import { FileProcessor } from "./files/file-options.js";
3
+ import { Field, FieldMeta } from "./field.js";
4
+ //#region src/file-field.d.ts
5
+ /** `f.file()`: a text column holding a file's URI, uploaded through `:upload`. */
6
+ export declare class FileField<V> extends Field<V> {
7
+ /** Allowed types, checked against the type detected from the content: `['image/*', 'application/pdf']`. */
8
+ accept(types: readonly string[]): FileField<V>;
9
+ /** The largest upload, as bytes or text such as `10 MB`; default 50 MB. */
10
+ maxSize(size: number | string): FileField<V>;
11
+ /** Stored with the `public` provider, at a permanent URL anyone with it may open. */
12
+ public(): FileField<V>;
13
+ /** Stored with this provider of `files.providers`; default `private`. */
14
+ storage(provider: string): FileField<V>;
15
+ /**
16
+ * Values computed from each upload, written to other fields of the resource (read-only ones) in the same write as the
17
+ * file: `{ image_width: imageSize('width') }`. Clearing the file clears them.
18
+ */
19
+ derive(processors: Record<string, FileProcessor>): FileField<V>;
20
+ optional(): FileField<V | null>;
21
+ readOnly(): FileField<V>;
22
+ column(name: string): FileField<V>;
23
+ access(rules: FieldAccess): FileField<V>;
24
+ protected with(patch: Partial<FieldMeta>): FileField<V>;
25
+ private withFile;
26
+ }
27
+ //#endregion
@@ -0,0 +1,61 @@
1
+ import { Field } from "./field.js";
2
+ import { checkedAccept, checkedProvider, parseSize } from "./files/file-options.js";
3
+ //#region src/file-field.ts
4
+ /** `f.file()`: a text column holding a file's URI, uploaded through `:upload`. */
5
+ var FileField = class FileField extends Field {
6
+ /** Allowed types, checked against the type detected from the content: `['image/*', 'application/pdf']`. */
7
+ accept(types) {
8
+ return this.withFile({ accept: checkedAccept(types) });
9
+ }
10
+ /** The largest upload, as bytes or text such as `10 MB`; default 50 MB. */
11
+ maxSize(size) {
12
+ return this.withFile({ maxSize: parseSize(size) });
13
+ }
14
+ /** Stored with the `public` provider, at a permanent URL anyone with it may open. */
15
+ public() {
16
+ return this.storage("public");
17
+ }
18
+ /** Stored with this provider of `files.providers`; default `private`. */
19
+ storage(provider) {
20
+ return this.withFile({ provider: checkedProvider(provider) });
21
+ }
22
+ /**
23
+ * Values computed from each upload, written to other fields of the resource (read-only ones) in the same write as the
24
+ * file: `{ image_width: imageSize('width') }`. Clearing the file clears them.
25
+ */
26
+ derive(processors) {
27
+ return this.withFile({ derive: {
28
+ ...this.meta.file.derive,
29
+ ...processors
30
+ } });
31
+ }
32
+ optional() {
33
+ return this.with({ nullable: true });
34
+ }
35
+ readOnly() {
36
+ return this.with({ readOnly: true });
37
+ }
38
+ column(name) {
39
+ return this.with({ column: name });
40
+ }
41
+ access(rules) {
42
+ return this.with({ access: {
43
+ ...this.meta.access,
44
+ ...rules
45
+ } });
46
+ }
47
+ with(patch) {
48
+ return new FileField(this.def, {
49
+ ...this.meta,
50
+ ...patch
51
+ });
52
+ }
53
+ withFile(patch) {
54
+ return this.with({ file: {
55
+ ...this.meta.file,
56
+ ...patch
57
+ } });
58
+ }
59
+ };
60
+ //#endregion
61
+ export { FileField };
@@ -0,0 +1,34 @@
1
+ //#region src/files/file-options.d.ts
2
+ /** What a processor gets: the stored upload's decided type, size and name, its first bytes, and the whole of it on demand. */
3
+ export type FileProcessorInput = {
4
+ name: string;
5
+ type: string;
6
+ size: number;
7
+ /** The first bytes of the content (up to 256 KB), enough for image headers. */
8
+ head: Uint8Array;
9
+ /** The whole content, read from storage. */
10
+ read(): Promise<ReadableStream<Uint8Array>>;
11
+ };
12
+ /**
13
+ * Computes a value for another column from an upload (`.derive()`). It runs once, on the upload route; a file whose type
14
+ * is not in `accepts` leaves the column null.
15
+ */
16
+ export type FileProcessor = {
17
+ /** Exact types and `type/*` wildcards; every type when omitted. */
18
+ accepts?: readonly string[];
19
+ run: (file: FileProcessorInput) => unknown;
20
+ };
21
+ /** A file field's settings, as the builder collects them. */
22
+ export type FileOptions = {
23
+ accept: string[];
24
+ maxSize: number;
25
+ provider: string;
26
+ derive?: Record<string, FileProcessor>;
27
+ };
28
+ /** Bytes from a number or text such as `10 MB` (decimal units) or `5 MiB` (binary units). */
29
+ export declare const parseSize: (size: number | string) => number;
30
+ /** Allowed types, lowercased: exact types and `type/*` wildcards; `*` alone is refused so that every type is a choice. */
31
+ export declare const checkedAccept: (types: readonly string[]) => string[];
32
+ /** A provider name: lowercase, at most 32 characters, and not a scheme browsers already know. */
33
+ export declare const checkedProvider: (name: string) => string;
34
+ //#endregion
@@ -0,0 +1,47 @@
1
+ //#region src/files/file-options.ts
2
+ const defaultMaxSize = 5e7;
3
+ const units = {
4
+ b: 1,
5
+ kb: 1e3,
6
+ mb: 1e6,
7
+ gb: 1e9,
8
+ kib: 1024,
9
+ mib: 1024 ** 2,
10
+ gib: 1024 ** 3
11
+ };
12
+ /** Bytes from a number or text such as `10 MB` (decimal units) or `5 MiB` (binary units). */
13
+ const parseSize = (size) => {
14
+ if (typeof size === "number") {
15
+ if (!Number.isSafeInteger(size) || size <= 0) throw new Error(`A file size must be a positive whole number of bytes, not ${size}`);
16
+ return size;
17
+ }
18
+ const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]+)?\s*$/i.exec(size);
19
+ const unit = units[(match?.[2] ?? "b").toLowerCase()];
20
+ if (!match || !unit) throw new Error(`"${size}" is not a size; write it like "10 MB" or "512 KiB"`);
21
+ return parseSize(Math.round(Number(match[1]) * unit));
22
+ };
23
+ const typePattern = /^(?:\*\/\*|[a-z0-9][a-z0-9!#$&^_.+-]*\/(?:\*|[a-z0-9][a-z0-9!#$&^_.+-]*))$/;
24
+ /** Allowed types, lowercased: exact types and `type/*` wildcards; `*` alone is refused so that every type is a choice. */
25
+ const checkedAccept = (types) => types.map((type) => {
26
+ const lower = type.trim().toLowerCase();
27
+ if (!typePattern.test(lower)) throw new Error(`"${type}" is not a media type or a type/* wildcard`);
28
+ return lower;
29
+ });
30
+ const providerPattern = /^[a-z][a-z0-9-]{0,31}$/;
31
+ const reservedProviders = /* @__PURE__ */ new Set([
32
+ "http",
33
+ "https",
34
+ "data",
35
+ "blob",
36
+ "file",
37
+ "javascript",
38
+ "mailto"
39
+ ]);
40
+ /** A provider name: lowercase, at most 32 characters, and not a scheme browsers already know. */
41
+ const checkedProvider = (name) => {
42
+ if (!providerPattern.test(name)) throw new Error(`"${name}" is not a file provider name: lowercase letters, digits and dashes, starting with a letter`);
43
+ if (reservedProviders.has(name)) throw new Error(`"${name}" cannot name a file provider: stored values would read as ${name}: links`);
44
+ return name;
45
+ };
46
+ //#endregion
47
+ export { checkedAccept, checkedProvider, defaultMaxSize, parseSize };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { ActionModel, ActionRun, ChartGranularity, ChartModel, ChartRange, CheckedFilter, CompareOp, DatePreset, DurationLiteral, DurationUnit, FieldFormat, FieldModel, FieldPath, FieldType, FieldViewModel, FilterError, FilterErrorCode, FilterExpr, FilterOf, FilterValue, FilterWidgetModel, KeyValue, LayoutItemModel, ListModel, ListQuery, Literal, NavModel, NavRecentModel, NowValue, OrderByItem, RelatedModel, ResourceModel, SaveFeedback, SearchMatch, SearchResultModel, SortSpec, Span, StatusTone, UserMenuItemModel, UserMenuModel, ViewModel } from "./model.js";
1
+ import { ActionModel, ActionRun, ChartGranularity, ChartModel, ChartRange, CheckedFilter, CompareOp, DatePreset, DurationLiteral, DurationUnit, FieldFormat, FieldModel, FieldPath, FieldType, FieldViewModel, FileFieldModel, FilterError, FilterErrorCode, FilterExpr, FilterOf, FilterValue, FilterWidgetModel, KeyValue, LayoutItemModel, ListModel, ListQuery, Literal, NavModel, NavRecentModel, NowValue, OrderByItem, RelatedModel, ResourceModel, SaveFeedback, SearchMatch, SearchResultModel, SortSpec, Span, StatusTone, UserMenuItemModel, UserMenuModel, ViewModel } from "./model.js";
2
2
  import { AccessAction, AccessContext, AccessFn, AccessResult, AccessUser, FieldAccess, FieldRule } from "./access/types.js";
3
+ import { FileProcessor, FileProcessorInput, checkedAccept, checkedProvider, parseSize } from "./files/file-options.js";
3
4
  import { Field } from "./field.js";
5
+ import { FileField } from "./file-field.js";
4
6
  import { f } from "./fields.js";
5
7
  import { InferRecord, RecordKey } from "./resource-types.js";
6
8
  import { resource } from "./resource.js";
@@ -28,4 +30,4 @@ import "./access/index.js";
28
30
  import { AssistantAction, AssistantActionRequest, AssistantCardPart, AssistantDiff, AssistantEvent, AssistantMessage, AssistantMessageRequest, AssistantPart, AssistantState, AssistantStep, AssistantStepState, AssistantTablePart, AssistantTextPart, AssistantTone, AssistantWidgetPart } from "./assistant/protocol.js";
29
31
  import { applyAssistantEvent, emptyAssistantState } from "./assistant/apply-event.js";
30
32
  import { readEventStream } from "./assistant/event-stream.js";
31
- export { type AccessAction, type AccessContext, type AccessFn, type AccessResult, type AccessSource, type AccessUser, type ActionModel, type ActionRun, type AssistantAction, type AssistantActionRequest, type AssistantCardPart, type AssistantDiff, type AssistantEvent, type AssistantMessage, type AssistantMessageRequest, type AssistantPart, type AssistantState, type AssistantStep, type AssistantStepState, type AssistantTablePart, type AssistantTextPart, type AssistantTone, type AssistantWidgetPart, type Capability, type CapabilityAction, type CapabilityScope, type ChartGranularity, type ChartModel, type ChartRange, type CheckResult, type CheckedFilter, type CompareOp, type DatePreset, type DurationLiteral, type DurationUnit, type EvaluateOptions, type Field, type FieldAccess, type FieldFormat, type FieldModel, type FieldPath, type FieldRule, type FieldType, type FieldViewModel, type FilterError, type FilterErrorCode, type FilterExpr, type FilterLimits, type FilterOf, type FilterParse, type FilterValue, type FilterWidgetModel, type InferRecord, type KeyType, type KeyValue, type LayoutItemModel, type ListModel, type ListQuery, type Literal, type NavModel, type NavRecentModel, type NowValue, type OrderByCheck, type OrderByItem, type OrderByParse, type RecordKey, type RelatedModel, type ResolveOptions, type ResolvedAccess, type ResourceModel, type Roles, type RolesOptions, type RowFilterOperation, type Rule, type SaveFeedback, type SearchMatch, type SearchResultModel, type SearchTerm, type SortSpec, type Span, type StatusTone, type UserMenuItemModel, type UserMenuModel, type UserMenuSource, type ViewModel, accessWarnings, applyAssistantEvent, checkFilter, checkOrderBy, checkRecord, createWhere, decodeKey, defaultRecentLimit, defineRoles, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldsUsedBy, keyTypes, layout as l, matchedSearchField, matchesSearch, navRecentErrors, parseFilter, parseOrderBy, pickView, printFilter, printOrderBy, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, statusTones, termMatches, userMenu, view, where };
33
+ export { type AccessAction, type AccessContext, type AccessFn, type AccessResult, type AccessSource, type AccessUser, type ActionModel, type ActionRun, type AssistantAction, type AssistantActionRequest, type AssistantCardPart, type AssistantDiff, type AssistantEvent, type AssistantMessage, type AssistantMessageRequest, type AssistantPart, type AssistantState, type AssistantStep, type AssistantStepState, type AssistantTablePart, type AssistantTextPart, type AssistantTone, type AssistantWidgetPart, type Capability, type CapabilityAction, type CapabilityScope, type ChartGranularity, type ChartModel, type ChartRange, type CheckResult, type CheckedFilter, type CompareOp, type DatePreset, type DurationLiteral, type DurationUnit, type EvaluateOptions, type Field, type FieldAccess, type FieldFormat, type FieldModel, type FieldPath, type FieldRule, type FieldType, type FieldViewModel, type FileField, type FileFieldModel, type FileProcessor, type FileProcessorInput, type FilterError, type FilterErrorCode, type FilterExpr, type FilterLimits, type FilterOf, type FilterParse, type FilterValue, type FilterWidgetModel, type InferRecord, type KeyType, type KeyValue, type LayoutItemModel, type ListModel, type ListQuery, type Literal, type NavModel, type NavRecentModel, type NowValue, type OrderByCheck, type OrderByItem, type OrderByParse, type RecordKey, type RelatedModel, type ResolveOptions, type ResolvedAccess, type ResourceModel, type Roles, type RolesOptions, type RowFilterOperation, type Rule, type SaveFeedback, type SearchMatch, type SearchResultModel, type SearchTerm, type SortSpec, type Span, type StatusTone, type UserMenuItemModel, type UserMenuModel, type UserMenuSource, type ViewModel, accessWarnings, applyAssistantEvent, checkFilter, checkOrderBy, checkRecord, checkedAccept, checkedProvider, createWhere, decodeKey, defaultRecentLimit, defineRoles, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldsUsedBy, keyTypes, layout as l, matchedSearchField, matchesSearch, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, printFilter, printOrderBy, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, statusTones, termMatches, userMenu, view, where };
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { checkedAccept, checkedProvider, parseSize } from "./files/file-options.js";
1
2
  import { f } from "./fields.js";
2
3
  import { resource } from "./resource.js";
3
4
  import { parseFilter } from "./filter/parse.js";
@@ -20,4 +21,4 @@ import { restrictModel } from "./access/restrict.js";
20
21
  import { accessWarnings, checkRecord, fieldsUsedBy, resolveAccess } from "./access/resolve.js";
21
22
  import { applyAssistantEvent, emptyAssistantState } from "./assistant/apply-event.js";
22
23
  import { readEventStream } from "./assistant/event-stream.js";
23
- export { accessWarnings, applyAssistantEvent, checkFilter, checkOrderBy, checkRecord, createWhere, decodeKey, defaultRecentLimit, defineRoles, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldsUsedBy, keyTypes, layout as l, matchedSearchField, matchesSearch, navRecentErrors, parseFilter, parseOrderBy, pickView, printFilter, printOrderBy, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, statusTones, termMatches, userMenu, view, where };
24
+ export { accessWarnings, applyAssistantEvent, checkFilter, checkOrderBy, checkRecord, checkedAccept, checkedProvider, createWhere, decodeKey, defaultRecentLimit, defineRoles, emptyAssistantState, encodeKey, enumLabel, evaluateFilter, f, fieldsUsedBy, keyTypes, layout as l, matchedSearchField, matchesSearch, navRecentErrors, parseFilter, parseOrderBy, parseSize, pickView, printFilter, printOrderBy, readEventStream, resolveAccess, resource, restrictModel, rule, searchTerms, statusTones, termMatches, userMenu, view, where };
package/dist/model.d.ts CHANGED
@@ -22,6 +22,14 @@ export type FieldModel = {
22
22
  } | {
23
23
  db: true;
24
24
  };
25
+ /** A file field's allowed types (empty: any), largest upload in bytes, provider, and the fields `.derive()` writes. */
26
+ file?: FileFieldModel;
27
+ };
28
+ export type FileFieldModel = {
29
+ accept: string[];
30
+ maxSize: number;
31
+ provider: string;
32
+ derive?: string[];
25
33
  };
26
34
  /** `digitsEnd`: only the end of the value's digits, spaces, `+` and dashes ignored (phone numbers). */
27
35
  export type SearchMatch = 'digitsEnd';
@@ -28,6 +28,12 @@ const fieldModel = (name, field) => {
28
28
  ...meta.relation && { relation: {
29
29
  resource: meta.relation.resource,
30
30
  columns: meta.relation.columns ?? [column]
31
+ } },
32
+ ...meta.file && { file: {
33
+ accept: [...meta.file.accept],
34
+ maxSize: meta.file.maxSize,
35
+ provider: meta.file.provider,
36
+ ...meta.file.derive && Object.keys(meta.file.derive).length > 0 && { derive: Object.keys(meta.file.derive) }
31
37
  } }
32
38
  };
33
39
  };
@@ -60,9 +66,28 @@ const sensitiveProblems = (model) => Object.values(model.fields).filter((field)
60
66
  model.softDelete
61
67
  ].includes(field.name) ? ["a key, tenant, owner or soft delete field"] : []
62
68
  ].map((problem) => `sensitive field "${field.name}" cannot be ${problem}`));
69
+ const fileProblems = (model) => Object.values(model.fields).filter((field) => field.file).flatMap((field) => [
70
+ ...field.filterable ? ["cannot be filterable"] : [],
71
+ ...field.sortable ? ["cannot be sortable"] : [],
72
+ ...field.aliases.length > 0 ? ["cannot be aliased"] : [],
73
+ ...field.sensitive ? ["cannot be sensitive (not implemented)"] : [],
74
+ ...model.search?.includes(field.name) ? ["cannot be searched"] : [],
75
+ ...[
76
+ ...model.primaryKey,
77
+ model.tenant,
78
+ model.owner,
79
+ model.softDelete
80
+ ].includes(field.name) ? ["cannot be a key, tenant, owner or soft delete field"] : [],
81
+ ...(field.file.derive ?? []).flatMap((target) => {
82
+ const derived = Object.hasOwn(model.fields, target) ? model.fields[target] : void 0;
83
+ if (!derived) return [`derives "${target}", which is not a field`];
84
+ if (target === field.name || derived.file) return [`derives "${target}", which is a file field`];
85
+ return derived.readOnly ? [] : [`derives "${target}", which must be readOnly()`];
86
+ })
87
+ ].map((problem) => `file field "${field.name}" ${problem}`));
63
88
  const buildResourceModel = (state) => {
64
89
  const model = assembleModel(state);
65
- const problems = sensitiveProblems(model);
90
+ const problems = [...sensitiveProblems(model), ...fileProblems(model)];
66
91
  if (problems.length > 0) throw new Error(`Resource "${model.name}": ${problems.join("; ")}`);
67
92
  return model;
68
93
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/schema",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Resource, field, view and access definitions for Protobase configs.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {