@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.
- package/CHANGELOG.md +329 -0
- package/README.md +290 -0
- package/package.json +56 -0
- package/src/adapter-support.ts +178 -0
- package/src/crud-doctrine.ts +43 -0
- package/src/index.ts +48 -0
- package/src/jsonfile/index.ts +6 -0
- package/src/jsonfile/runtime.ts +700 -0
- package/src/jsonfile/types.ts +50 -0
- package/src/store.ts +528 -0
- package/src/testing.ts +175 -0
- package/src/trails/crud.ts +423 -0
- package/src/trails/index.ts +20 -0
- package/src/trails/reconcile.ts +299 -0
- package/src/trails/sync.ts +274 -0
- package/src/trails/utils.ts +117 -0
- package/src/types.ts +654 -0
|
@@ -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';
|