@protobase/schema 0.8.0 → 0.8.2

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.
@@ -4,7 +4,8 @@ import { ResolvedAccess } from "./resolve.js";
4
4
  /**
5
5
  * The resource as one user may see it: only readable fields remain, and a field is `readOnly`
6
6
  * unless it is writable on create or update. `search` (and `searchMatch`) shrinks to readable fields, and `tenant`,
7
- * `softDelete` and `owner` are dropped when their field is hidden. Key columns always stay,
7
+ * `softDelete` and `owner` are dropped when their field is hidden. The event catalogue, whose conditions may use any
8
+ * field, is dropped. Key columns always stay,
8
9
  * because records are addressed by them.
9
10
  *
10
11
  * Use the restricted model for everything user-facing (checking filters, sorts, queries,
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * The resource as one user may see it: only readable fields remain, and a field is `readOnly`
4
4
  * unless it is writable on create or update. `search` (and `searchMatch`) shrinks to readable fields, and `tenant`,
5
- * `softDelete` and `owner` are dropped when their field is hidden. Key columns always stay,
5
+ * `softDelete` and `owner` are dropped when their field is hidden. The event catalogue, whose conditions may use any
6
+ * field, is dropped. Key columns always stay,
6
7
  * because records are addressed by them.
7
8
  *
8
9
  * Use the restricted model for everything user-facing (checking filters, sorts, queries,
@@ -16,7 +17,7 @@ const restrictModel = (model, access) => {
16
17
  const keep = (name) => name !== void 0 && visible.has(name) ? name : void 0;
17
18
  const search = model.search?.filter((name) => visible.has(name));
18
19
  const searchMatch = model.searchMatch && Object.entries(model.searchMatch).filter(([name]) => visible.has(name));
19
- const { search: _search, searchMatch: _searchMatch, tenant: _tenant, softDelete: _softDelete, owner: _owner, ...rest } = model;
20
+ const { search: _search, searchMatch: _searchMatch, tenant: _tenant, softDelete: _softDelete, owner: _owner, events: _events, ...rest } = model;
20
21
  const tenant = keep(model.tenant);
21
22
  const softDelete = keep(model.softDelete);
22
23
  const owner = keep(model.owner);
@@ -0,0 +1,70 @@
1
+ import { EventOperation } from "../model.js";
2
+ import { Ref } from "../refs.js";
3
+ //#region src/events/event-helpers.d.ts
4
+ /** Who made a write through the API; writes from anywhere else have none. */
5
+ export type EventActor = {
6
+ id: string | number;
7
+ roles: string[];
8
+ };
9
+ /**
10
+ * One record's change in one transaction, as an event's `match` sees it. Records use field names and leave out
11
+ * sensitive fields, whose names `changed` still lists.
12
+ */
13
+ export type EventContext<R = Record<string, unknown>> = {
14
+ operation: EventOperation;
15
+ before?: Partial<R>;
16
+ after?: Partial<R>;
17
+ changed: string[];
18
+ /** `null` for a write made outside the API (psql, another service, a database trigger). */
19
+ actor: EventActor | null;
20
+ source: 'api' | 'database';
21
+ tenant?: string | number;
22
+ /** The writing transaction's id. */
23
+ transaction: string;
24
+ };
25
+ export type EventMatch<R> = (context: EventContext<R>) => boolean;
26
+ /** `when` and `from` are filters in the language of access rules and views, on the record after and before the change. */
27
+ export type CreatedOptions<R> = {
28
+ when?: string;
29
+ match?: EventMatch<R>;
30
+ };
31
+ export type UpdatedOptions<R> = {
32
+ when?: string;
33
+ from?: string;
34
+ changed?: Ref[];
35
+ match?: EventMatch<R>;
36
+ };
37
+ export type DeletedOptions<R> = {
38
+ from?: string;
39
+ match?: EventMatch<R>;
40
+ };
41
+ export type UndeletedOptions<R> = {
42
+ when?: string;
43
+ from?: string;
44
+ match?: EventMatch<R>;
45
+ };
46
+ /** An event as declared; `buildEventModels` checks it against the resource. */
47
+ export type EventSpec = {
48
+ name: string;
49
+ operations: EventOperation[];
50
+ when?: string;
51
+ from?: string;
52
+ becomes?: string;
53
+ changed?: string[];
54
+ match?: EventMatch<any>;
55
+ /** Kept only when the resource soft deletes: the `undeleted` of `e.crud()`. */
56
+ softDeleteOnly?: true;
57
+ };
58
+ /** The `e` of `.events((e, r) => [...])`. Options combine with AND. */
59
+ export declare const eventHelpers: <R>() => {
60
+ created: (name?: string, options?: CreatedOptions<R>) => EventSpec;
61
+ updated: (name?: string, options?: UpdatedOptions<R>) => EventSpec;
62
+ deleted: (name?: string, options?: DeletedOptions<R>) => EventSpec;
63
+ undeleted: (name?: string, options?: UndeletedOptions<R>) => EventSpec;
64
+ /** Fires when `condition` turns true: on a create that meets it, or an update after which it holds and before which it did not. */
65
+ becomes: (name: string, condition: string) => EventSpec;
66
+ /** created, updated and deleted, plus undeleted when the resource soft deletes. */
67
+ crud: () => EventSpec[];
68
+ };
69
+ export type EventHelpers<R> = ReturnType<typeof eventHelpers<R>>;
70
+ //#endregion
@@ -0,0 +1,38 @@
1
+ //#region src/events/event-helpers.ts
2
+ const spec = (name, operation, { changed, ...options }) => ({
3
+ name,
4
+ operations: [operation],
5
+ ...options,
6
+ ...changed && { changed: changed.map((ref) => ref.name) }
7
+ });
8
+ /** The `e` of `.events((e, r) => [...])`. Options combine with AND. */
9
+ const eventHelpers = () => {
10
+ const created = (name = "created", options = {}) => spec(name, "create", options);
11
+ const updated = (name = "updated", options = {}) => spec(name, "update", options);
12
+ const deleted = (name = "deleted", options = {}) => spec(name, "delete", options);
13
+ const undeleted = (name = "undeleted", options = {}) => spec(name, "undelete", options);
14
+ return {
15
+ created,
16
+ updated,
17
+ deleted,
18
+ undeleted,
19
+ /** Fires when `condition` turns true: on a create that meets it, or an update after which it holds and before which it did not. */
20
+ becomes: (name, condition) => ({
21
+ name,
22
+ operations: ["create", "update"],
23
+ becomes: condition
24
+ }),
25
+ /** created, updated and deleted, plus undeleted when the resource soft deletes. */
26
+ crud: () => [
27
+ created(),
28
+ updated(),
29
+ deleted(),
30
+ {
31
+ ...undeleted(),
32
+ softDeleteOnly: true
33
+ }
34
+ ]
35
+ };
36
+ };
37
+ //#endregion
38
+ export { eventHelpers };
@@ -0,0 +1,9 @@
1
+ import { CheckedFilter, ResourceModel } from "../model.js";
2
+ import "./event-helpers.js";
3
+ //#region src/events/event-models.d.ts
4
+ /** The filter checked against the resource, or what is wrong with it. */
5
+ export declare const checkEventFilter: (model: ResourceModel, source: string) => {
6
+ filter?: CheckedFilter;
7
+ problem?: string;
8
+ };
9
+ //#endregion
@@ -0,0 +1,69 @@
1
+ import { parseFilter } from "../filter/parse.js";
2
+ import { checkFilter } from "../filter/check.js";
3
+ import { fieldsUsedBy } from "../access/resolve.js";
4
+ //#region src/events/event-models.ts
5
+ const namePattern = /^[a-z][a-zA-Z0-9]*$/;
6
+ const trusted = (model) => ({
7
+ ...model,
8
+ fields: Object.fromEntries(Object.entries(model.fields).map(([name, field]) => [name, {
9
+ ...field,
10
+ filterable: true
11
+ }]))
12
+ });
13
+ const needsDatabase = (filter) => {
14
+ if (filter.kind === "and" || filter.kind === "or") return filter.args.some(needsDatabase);
15
+ if (filter.kind === "not") return needsDatabase(filter.arg);
16
+ return filter.kind === "search" || filter.kind === "similar";
17
+ };
18
+ /** The filter checked against the resource, or what is wrong with it. */
19
+ const checkEventFilter = (model, source) => {
20
+ const parsed = parseFilter(source);
21
+ if (!parsed.ok) return { problem: parsed.errors[0].message };
22
+ if (!parsed.ast) return { problem: "is empty" };
23
+ const checked = checkFilter(trusted(model), parsed.ast);
24
+ if (!checked.ok) return { problem: checked.errors[0].message };
25
+ const sensitive = fieldsUsedBy(checked.filter).filter((name) => model.fields[name]?.sensitive);
26
+ if (sensitive.length > 0) return { problem: `uses sensitive field "${sensitive[0]}", which events never hold; use \`changed\` instead` };
27
+ if (needsDatabase(checked.filter)) return { problem: "uses search() or similar(), which need the database" };
28
+ return { filter: checked.filter };
29
+ };
30
+ const specProblems = (model, spec) => [
31
+ ...namePattern.test(spec.name) ? [] : ["is not a camelCase name"],
32
+ ...spec.operations.includes("undelete") && !model.softDelete ? ["needs a soft delete field (`.softDelete()`)"] : [],
33
+ ...[
34
+ "when",
35
+ "from",
36
+ "becomes"
37
+ ].flatMap((option) => {
38
+ const source = spec[option];
39
+ if (source === void 0) return [];
40
+ const { problem } = checkEventFilter(model, source);
41
+ return problem ? [`${option} ${problem}`] : [];
42
+ }),
43
+ ...spec.changed?.length === 0 ? ["changed lists no fields"] : []
44
+ ].map((problem) => `event "${spec.name}" ${problem}`);
45
+ const eventModel = (resource, { name, operations, when, from, becomes, changed, match }) => ({
46
+ name: `${resource}.${name}`,
47
+ operations: [...operations],
48
+ ...when !== void 0 && { when },
49
+ ...from !== void 0 && { from },
50
+ ...becomes !== void 0 && { becomes },
51
+ ...changed && { changed: [...changed] },
52
+ ...match && { match: true }
53
+ });
54
+ /** The specs that apply to the resource: `e.crud()`'s undeleted only when it soft deletes. */
55
+ const activeSpecs = (model, specs) => specs.filter((spec) => !spec.softDeleteOnly || model.softDelete);
56
+ /** The resource's event catalogue, and what is wrong with its declarations. */
57
+ const buildEventModels = (model, specs) => {
58
+ const active = activeSpecs(model, specs);
59
+ const names = active.map((spec) => spec.name);
60
+ const twice = [...new Set(names.filter((name, index) => names.indexOf(name) !== index))];
61
+ return {
62
+ events: active.map((spec) => eventModel(model.name, spec)),
63
+ problems: [...twice.map((name) => `event "${name}" is declared twice`), ...active.flatMap((spec) => specProblems(model, spec))]
64
+ };
65
+ };
66
+ /** The code predicates of the resource's events, by full event name. */
67
+ const eventMatches = (model, specs) => Object.fromEntries(activeSpecs(model, specs).flatMap((spec) => spec.match ? [[`${model.name}.${spec.name}`, spec.match]] : []));
68
+ //#endregion
69
+ export { activeSpecs, buildEventModels, checkEventFilter, eventMatches };
package/dist/index.d.ts CHANGED
@@ -1,11 +1,13 @@
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";
1
+ import { ActionModel, ActionRun, ChartGranularity, ChartModel, ChartRange, CheckedFilter, CompareOp, DatePreset, DurationLiteral, DurationUnit, EventModel, EventOperation, 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
3
  import { FileProcessor, FileProcessorInput, checkedAccept, checkedProvider, parseSize } from "./files/file-options.js";
4
4
  import { Field } from "./field.js";
5
5
  import { FileField } from "./file-field.js";
6
6
  import { f } from "./fields.js";
7
+ import { EventActor, EventContext, EventHelpers, EventMatch, EventSpec } from "./events/event-helpers.js";
7
8
  import { InferRecord, RecordKey } from "./resource-types.js";
8
9
  import { resource } from "./resource.js";
10
+ import { checkEventFilter } from "./events/event-models.js";
9
11
  import { view } from "./view.js";
10
12
  import { enumLabel } from "./enum-label.js";
11
13
  import { SearchTerm, matchedSearchField, matchesSearch, searchTerms, termMatches } from "./search-match.js";
@@ -30,4 +32,4 @@ import "./access/index.js";
30
32
  import { AssistantAction, AssistantActionRequest, AssistantCardPart, AssistantDiff, AssistantEvent, AssistantMessage, AssistantMessageRequest, AssistantPart, AssistantState, AssistantStep, AssistantStepState, AssistantTablePart, AssistantTextPart, AssistantTone, AssistantWidgetPart } from "./assistant/protocol.js";
31
33
  import { applyAssistantEvent, emptyAssistantState } from "./assistant/apply-event.js";
32
34
  import { readEventStream } from "./assistant/event-stream.js";
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 };
35
+ 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 EventActor, type EventContext, type EventHelpers, type EventMatch, type EventModel, type EventOperation, type EventSpec, 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, checkEventFilter, 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,12 +1,14 @@
1
1
  import { checkedAccept, checkedProvider, parseSize } from "./files/file-options.js";
2
2
  import { f } from "./fields.js";
3
- import { resource } from "./resource.js";
4
3
  import { parseFilter } from "./filter/parse.js";
5
4
  import { checkFilter } from "./filter/check.js";
6
5
  import { evaluateFilter } from "./filter/evaluate.js";
7
6
  import { printFilter } from "./filter/print.js";
8
7
  import { checkOrderBy, parseOrderBy, printOrderBy } from "./filter/order-by.js";
9
8
  import { createWhere, where } from "./filter/where.js";
9
+ import { accessWarnings, checkRecord, fieldsUsedBy, resolveAccess } from "./access/resolve.js";
10
+ import { checkEventFilter } from "./events/event-models.js";
11
+ import { resource } from "./resource.js";
10
12
  import { defaultRecentLimit, navRecentErrors, statusTones } from "./nav-recent.js";
11
13
  import { view } from "./view.js";
12
14
  import { enumLabel } from "./enum-label.js";
@@ -18,7 +20,6 @@ import { rule } from "./access/rule.js";
18
20
  import { defineRoles } from "./access/roles.js";
19
21
  import { pickView } from "./access/pick-view.js";
20
22
  import { restrictModel } from "./access/restrict.js";
21
- import { accessWarnings, checkRecord, fieldsUsedBy, resolveAccess } from "./access/resolve.js";
22
23
  import { applyAssistantEvent, emptyAssistantState } from "./assistant/apply-event.js";
23
24
  import { readEventStream } from "./assistant/event-stream.js";
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 };
25
+ export { accessWarnings, applyAssistantEvent, checkEventFilter, 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
@@ -49,6 +49,26 @@ export type ResourceModel = {
49
49
  searchMatch?: Record<string, SearchMatch>;
50
50
  /** The field holding the owning user's id; `.own` capability scopes filter on it. */
51
51
  owner?: string;
52
+ /** The events its changes raise (`.events()`), the catalogue subscriptions pick from. */
53
+ events?: EventModel[];
54
+ };
55
+ /** What happened to a record: `delete` and `undelete` include setting and clearing a soft delete field. */
56
+ export type EventOperation = 'create' | 'update' | 'delete' | 'undelete';
57
+ /**
58
+ * An event a resource declares. It fires for a change whose operation is listed and that passes every condition:
59
+ * `when` on the record after the change, `from` on the record before it, `becomes` turning true (false or no record
60
+ * before, true after), one of the `changed` fields changing, and a code predicate when `match` is set.
61
+ */
62
+ export type EventModel = {
63
+ /** `<resource>.<name>`, such as `orders.created`. */
64
+ name: string;
65
+ operations: EventOperation[];
66
+ when?: string;
67
+ from?: string;
68
+ becomes?: string;
69
+ changed?: string[];
70
+ /** A code predicate decides too; it stays in the config. */
71
+ match?: true;
52
72
  };
53
73
  export type SortSpec = Array<[field: string, direction: 'asc' | 'desc']>;
54
74
  export type KeyValue = string | number;
@@ -1,5 +1,6 @@
1
1
  import { SearchMatch } from "./model.js";
2
2
  import { FieldsInput } from "./refs.js";
3
+ import { EventSpec } from "./events/event-helpers.js";
3
4
  //#region src/resource-model.d.ts
4
5
  export type ResourceState = {
5
6
  name: string;
@@ -11,5 +12,6 @@ export type ResourceState = {
11
12
  search?: string[];
12
13
  searchMatch?: Record<string, SearchMatch>;
13
14
  owner?: string;
15
+ events?: EventSpec[];
14
16
  };
15
17
  //#endregion
@@ -1,3 +1,4 @@
1
+ import { buildEventModels } from "./events/event-models.js";
1
2
  import { snakeCase } from "./naming.js";
2
3
  //#region src/resource-model.ts
3
4
  const parseTable = (table) => {
@@ -87,9 +88,17 @@ const fileProblems = (model) => Object.values(model.fields).filter((field) => fi
87
88
  ].map((problem) => `file field "${field.name}" ${problem}`));
88
89
  const buildResourceModel = (state) => {
89
90
  const model = assembleModel(state);
90
- const problems = [...sensitiveProblems(model), ...fileProblems(model)];
91
+ const events = state.events && buildEventModels(model, state.events);
92
+ const problems = [
93
+ ...sensitiveProblems(model),
94
+ ...fileProblems(model),
95
+ ...events?.problems ?? []
96
+ ];
91
97
  if (problems.length > 0) throw new Error(`Resource "${model.name}": ${problems.join("; ")}`);
92
- return model;
98
+ return events && events.events.length > 0 ? {
99
+ ...model,
100
+ events: events.events
101
+ } : model;
93
102
  };
94
103
  const ignoredColumns = (fields = {}) => Object.entries(fields).filter(([, field]) => field === null).map(([key]) => snakeCase(key));
95
104
  //#endregion
@@ -1,8 +1,10 @@
1
1
  import { ResourceModel, SearchMatch } from "./model.js";
2
2
  import { FieldAccess } from "./access/types.js";
3
3
  import { FieldRefs, FieldsInput, Ref } from "./refs.js";
4
+ import { EventHelpers, EventMatch, EventSpec } from "./events/event-helpers.js";
4
5
  import { ResourceState } from "./resource-model.js";
5
6
  import { Access, Issue, KeyRefs, RecordOf } from "./resource-types.js";
7
+ import "./index.js";
6
8
  import { z } from "zod";
7
9
  //#region src/resource.d.ts
8
10
  type SearchRef = Ref & {
@@ -32,6 +34,13 @@ export declare class ResourceBuilder<F extends FieldsInput = {}, K = never> {
32
34
  search(pick: (r: SearchRefs<F>) => SearchRef[]): ResourceBuilder<F, K>;
33
35
  /** The field holding the owning user's id, which `.own` capability scopes filter on. */
34
36
  owner(pick: (r: FieldRefs<F>) => Ref): ResourceBuilder<F, K>;
37
+ /**
38
+ * The events changes to its records raise, captured in the database for every write, from the API or elsewhere:
39
+ * `(e, r) => [...e.crud(), e.becomes('large', 'total > 1000')]`.
40
+ */
41
+ events(define: (e: EventHelpers<RecordOf<F>>, r: FieldRefs<F>) => EventSpec[]): ResourceBuilder<F, K>;
42
+ /** The code predicates (`match`) of its events, by event name. */
43
+ eventMatches(): Record<string, EventMatch<any>>;
35
44
  validate(validator: Validator<F>): ResourceBuilder<F, K>;
36
45
  access(rules: Access<F>): ResourceBuilder<F, K>;
37
46
  /** Role-based rules per field, for fields that declared `.access(...)`. */
package/dist/resource.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { eventHelpers } from "./events/event-helpers.js";
2
+ import { eventMatches } from "./events/event-models.js";
1
3
  import { buildResourceModel, ignoredColumns } from "./resource-model.js";
2
4
  import { createRefs } from "./refs.js";
3
5
  import { z } from "zod";
@@ -56,6 +58,17 @@ var ResourceBuilder = class ResourceBuilder {
56
58
  owner(pick) {
57
59
  return this.next({ owner: pick(this.refs()).name });
58
60
  }
61
+ /**
62
+ * The events changes to its records raise, captured in the database for every write, from the API or elsewhere:
63
+ * `(e, r) => [...e.crud(), e.becomes('large', 'total > 1000')]`.
64
+ */
65
+ events(define) {
66
+ return this.next({ events: define(eventHelpers(), this.refs()) });
67
+ }
68
+ /** The code predicates (`match`) of its events, by event name. */
69
+ eventMatches() {
70
+ return eventMatches(this.toModel(), this.state.events ?? []);
71
+ }
59
72
  validate(validator) {
60
73
  return new ResourceBuilder(this.state, [...this.validators, validator], this.accessRules);
61
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/schema",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Resource, field, view and access definitions for Protobase configs.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {