@ontrails/store 0.2.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.
@@ -0,0 +1,178 @@
1
+ import {
2
+ attachLateBoundSignalRef,
3
+ cloneSignalWithId,
4
+ signal,
5
+ ValidationError,
6
+ } from '@ontrails/core';
7
+ import type { Signal } from '@ontrails/core';
8
+ import type { z } from 'zod';
9
+
10
+ import type {
11
+ AnyStoreDefinition,
12
+ AnyStoreTable,
13
+ StoreTableSignals,
14
+ } from './types.js';
15
+
16
+ type MutableTables<TStore extends AnyStoreDefinition> = {
17
+ -readonly [TName in keyof TStore['tables']]: TStore['tables'][TName];
18
+ };
19
+
20
+ type StoreSignalChange = 'created' | 'removed' | 'updated';
21
+
22
+ const storeSignalTokenCounter = Symbol.for(
23
+ '@ontrails/store.late-bound-signal-counter'
24
+ );
25
+
26
+ const takeStoreSignalTokenCounter = (): number => {
27
+ const globals = globalThis as Record<PropertyKey, unknown>;
28
+ const current = globals[storeSignalTokenCounter];
29
+ const next = typeof current === 'number' ? current : 0;
30
+ globals[storeSignalTokenCounter] = next + 1;
31
+ return next;
32
+ };
33
+
34
+ const createStoreSignalToken = (change: StoreSignalChange): string =>
35
+ `store-${change}-${takeStoreSignalTokenCounter()}`;
36
+
37
+ const createStoreSignalDescription = (
38
+ tableName: string,
39
+ change: StoreSignalChange
40
+ ): string => {
41
+ switch (change) {
42
+ case 'created': {
43
+ return `Fired after a "${tableName}" entity is created.`;
44
+ }
45
+ case 'removed': {
46
+ return `Fired after a "${tableName}" entity is removed.`;
47
+ }
48
+ case 'updated': {
49
+ return `Fired after a "${tableName}" entity is updated.`;
50
+ }
51
+ default: {
52
+ throw new Error(`Unsupported store signal change: ${change as string}`);
53
+ }
54
+ }
55
+ };
56
+
57
+ const createStoreSignal = <TPayload>(
58
+ tableName: string,
59
+ change: StoreSignalChange,
60
+ payload: z.ZodType<TPayload>
61
+ ): Signal<TPayload> =>
62
+ attachLateBoundSignalRef(
63
+ signal(`${tableName}.${change}`, {
64
+ description: createStoreSignalDescription(tableName, change),
65
+ payload,
66
+ }),
67
+ {
68
+ kind: 'store-derived',
69
+ token: createStoreSignalToken(change),
70
+ }
71
+ );
72
+
73
+ export const createStoreTableSignals = <TPayload>(
74
+ tableName: string,
75
+ payload: z.ZodType<TPayload>
76
+ ): StoreTableSignals<TPayload> =>
77
+ Object.freeze({
78
+ created: createStoreSignal(tableName, 'created', payload),
79
+ removed: createStoreSignal(tableName, 'removed', payload),
80
+ updated: createStoreSignal(tableName, 'updated', payload),
81
+ });
82
+
83
+ export const composeStoreSignalId = (
84
+ scope: string,
85
+ tableName: string,
86
+ change: StoreSignalChange
87
+ ): string => `${scope}:${tableName}.${change}`;
88
+
89
+ const bindTableSignals = (
90
+ scope: string,
91
+ table: AnyStoreTable
92
+ ): StoreTableSignals<unknown> =>
93
+ Object.freeze({
94
+ created: cloneSignalWithId(
95
+ table.signals.created,
96
+ composeStoreSignalId(scope, table.name, 'created')
97
+ ),
98
+ removed: cloneSignalWithId(
99
+ table.signals.removed,
100
+ composeStoreSignalId(scope, table.name, 'removed')
101
+ ),
102
+ updated: cloneSignalWithId(
103
+ table.signals.updated,
104
+ composeStoreSignalId(scope, table.name, 'updated')
105
+ ),
106
+ });
107
+
108
+ const collectStoreSignals = <TStore extends AnyStoreDefinition>(
109
+ normalized: MutableTables<TStore>,
110
+ tableNames: readonly Extract<keyof TStore['tables'], string>[]
111
+ ) =>
112
+ Object.freeze(
113
+ tableNames.flatMap((name) => {
114
+ const table = normalized[name];
115
+ return table === undefined
116
+ ? []
117
+ : [table.signals.created, table.signals.updated, table.signals.removed];
118
+ })
119
+ );
120
+
121
+ /**
122
+ * Verifies that a resource id is safe to compose into a scoped signal id.
123
+ *
124
+ * Scoped signal ids are matched by the `SCOPED_SIGNAL_ID` pattern in
125
+ * `@ontrails/core` (`^[^:\s]+:[^:.\s]+(?:\.[^:.\s]+)+$`). A resource id used
126
+ * as the scope segment must therefore be a non-empty string that contains
127
+ * neither `":"` nor any whitespace.
128
+ */
129
+ export const isValidResourceId = (resourceId: string): boolean =>
130
+ resourceId.length > 0 && !resourceId.includes(':') && !/\s/.test(resourceId);
131
+
132
+ const assertValidScope = (scope: string): void => {
133
+ if (!isValidResourceId(scope)) {
134
+ throw new ValidationError(
135
+ `Store resource id "${scope}" is invalid: must be a non-empty string with no ":" characters and no whitespace.`
136
+ );
137
+ }
138
+ };
139
+
140
+ export const bindStoreDefinition = <TStore extends AnyStoreDefinition>(
141
+ definition: TStore,
142
+ scope: string
143
+ ): TStore => {
144
+ assertValidScope(scope);
145
+
146
+ const tableNames = definition.tableNames as readonly Extract<
147
+ keyof TStore['tables'],
148
+ string
149
+ >[];
150
+ const tables = {} as MutableTables<TStore>;
151
+
152
+ for (const tableName of tableNames) {
153
+ const table = definition.tables[tableName];
154
+ if (table === undefined) {
155
+ continue;
156
+ }
157
+
158
+ tables[tableName] = Object.freeze({
159
+ ...table,
160
+ signals: bindTableSignals(scope, table),
161
+ }) as MutableTables<TStore>[typeof tableName];
162
+ }
163
+
164
+ const get =
165
+ 'get' in definition && typeof definition.get === 'function'
166
+ ? <TName extends Extract<keyof TStore['tables'], string>>(name: TName) =>
167
+ tables[name]
168
+ : undefined;
169
+
170
+ return Object.freeze({
171
+ ...definition,
172
+ ...(get ? { get } : {}),
173
+ signals: collectStoreSignals(tables, tableNames),
174
+ tables: Object.freeze(tables),
175
+ }) as TStore;
176
+ };
177
+
178
+ export type { StoreSignalChange };
@@ -0,0 +1,43 @@
1
+ export const crudOperations = [
2
+ 'create',
3
+ 'read',
4
+ 'update',
5
+ 'delete',
6
+ 'list',
7
+ ] as const;
8
+
9
+ export type CrudOperation = (typeof crudOperations)[number];
10
+
11
+ export interface CrudAccessorExpectation {
12
+ readonly fallback?: string | undefined;
13
+ readonly preferred: string;
14
+ readonly severityWhenNoFallback: 'error';
15
+ readonly severityWhenPreferredMissingWithFallback?: 'warn' | undefined;
16
+ }
17
+
18
+ export const crudAccessorExpectations = {
19
+ create: {
20
+ fallback: 'upsert',
21
+ preferred: 'insert',
22
+ severityWhenNoFallback: 'error',
23
+ severityWhenPreferredMissingWithFallback: 'warn',
24
+ },
25
+ delete: {
26
+ preferred: 'remove',
27
+ severityWhenNoFallback: 'error',
28
+ },
29
+ list: {
30
+ preferred: 'list',
31
+ severityWhenNoFallback: 'error',
32
+ },
33
+ read: {
34
+ preferred: 'get',
35
+ severityWhenNoFallback: 'error',
36
+ },
37
+ update: {
38
+ fallback: 'upsert',
39
+ preferred: 'update',
40
+ severityWhenNoFallback: 'error',
41
+ severityWhenPreferredMissingWithFallback: 'warn',
42
+ },
43
+ } as const satisfies Record<CrudOperation, CrudAccessorExpectation>;
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ export { crudAccessorExpectations, crudOperations } from './crud-doctrine.js';
2
+ export type {
3
+ CrudAccessorExpectation,
4
+ CrudOperation,
5
+ } from './crud-doctrine.js';
6
+ export { store, versionFieldName } from './store.js';
7
+ export type {
8
+ AnyStoreDefinition,
9
+ AnyStoreTable,
10
+ EntityOf,
11
+ FiltersOf,
12
+ FixtureInputOf,
13
+ FixtureOf,
14
+ FixturesOfInput,
15
+ GeneratedFieldsOfInput,
16
+ GeneratedKeysOf,
17
+ IdentityFieldOfInput,
18
+ IdentityOf,
19
+ IndexFieldsOfInput,
20
+ IndexedFieldsOfInput,
21
+ InsertOf,
22
+ ReadOnlyStoreConnection,
23
+ StoreAccessor,
24
+ ReadOnlyStoreTableAccessor,
25
+ ReferencesOfInput,
26
+ StoreAccessMode,
27
+ StoreConnection,
28
+ StoreAdapterOptions,
29
+ StoreDefinition,
30
+ StoreFieldKey,
31
+ StoreFixtureInput,
32
+ StoreFixtureRow,
33
+ StoreIdentifierOf,
34
+ StoreKind,
35
+ StoreListOptions,
36
+ StoreMockSeed,
37
+ StoreObjectSchema,
38
+ StoreOptions,
39
+ StoreSearchDefinition,
40
+ StoreTable,
41
+ StoreTableSignals,
42
+ StoreTableAccessor,
43
+ StoreTableConnection,
44
+ StoreTableInput,
45
+ StoreTablesInput,
46
+ UpsertOf,
47
+ UpdateOf,
48
+ } from './types.js';
@@ -0,0 +1,6 @@
1
+ export { connectJsonFile, jsonFile, jsonFile as store } from './runtime.js';
2
+ export type {
3
+ JsonFileConnection,
4
+ JsonFileStoreOptions,
5
+ JsonFileStoreResource,
6
+ } from './types.js';