@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
package/src/testing.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { expect } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AnyStoreTable,
|
|
5
|
+
EntityOf,
|
|
6
|
+
FiltersOf,
|
|
7
|
+
StoreAccessor,
|
|
8
|
+
StoreIdentifierOf,
|
|
9
|
+
UpsertOf,
|
|
10
|
+
} from './types.js';
|
|
11
|
+
|
|
12
|
+
export interface StoreAccessorContractSubject<TTable extends AnyStoreTable> {
|
|
13
|
+
readonly accessor: StoreAccessor<TTable>;
|
|
14
|
+
readonly dispose?: () => Promise<void> | void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StoreAccessorContractOptions<TTable extends AnyStoreTable> {
|
|
18
|
+
readonly createInput: () => UpsertOf<TTable>;
|
|
19
|
+
readonly createSubject:
|
|
20
|
+
| (() => StoreAccessorContractSubject<TTable>)
|
|
21
|
+
| (() => Promise<StoreAccessorContractSubject<TTable>>);
|
|
22
|
+
readonly expectCreated:
|
|
23
|
+
| ((entity: EntityOf<TTable>, input: UpsertOf<TTable>) => void)
|
|
24
|
+
| ((entity: EntityOf<TTable>, input: UpsertOf<TTable>) => Promise<void>);
|
|
25
|
+
readonly expectUpdated:
|
|
26
|
+
| ((
|
|
27
|
+
entity: EntityOf<TTable>,
|
|
28
|
+
previous: EntityOf<TTable>,
|
|
29
|
+
input: UpsertOf<TTable>
|
|
30
|
+
) => void)
|
|
31
|
+
| ((
|
|
32
|
+
entity: EntityOf<TTable>,
|
|
33
|
+
previous: EntityOf<TTable>,
|
|
34
|
+
input: UpsertOf<TTable>
|
|
35
|
+
) => Promise<void>);
|
|
36
|
+
readonly missingId: StoreIdentifierOf<TTable>;
|
|
37
|
+
readonly seedExisting?:
|
|
38
|
+
| ((accessor: StoreAccessor<TTable>) => Promise<EntityOf<TTable>>)
|
|
39
|
+
| ((accessor: StoreAccessor<TTable>) => EntityOf<TTable>);
|
|
40
|
+
readonly table: TTable;
|
|
41
|
+
readonly updateInput: (existing: EntityOf<TTable>) => UpsertOf<TTable>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StoreAccessorContractCase {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly run: () => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const listByIdentity = async <TTable extends AnyStoreTable>(
|
|
50
|
+
table: TTable,
|
|
51
|
+
accessor: StoreAccessor<TTable>,
|
|
52
|
+
entity: EntityOf<TTable>
|
|
53
|
+
): Promise<readonly EntityOf<TTable>[]> => {
|
|
54
|
+
const identity = table.identity as keyof EntityOf<TTable> & string;
|
|
55
|
+
const filters = {
|
|
56
|
+
[identity]: entity[identity],
|
|
57
|
+
} as FiltersOf<TTable>;
|
|
58
|
+
|
|
59
|
+
return await accessor.list(filters);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const withSubject = async <TTable extends AnyStoreTable, TResult>(
|
|
63
|
+
createSubject: StoreAccessorContractOptions<TTable>['createSubject'],
|
|
64
|
+
run: (subject: StoreAccessorContractSubject<TTable>) => Promise<TResult>
|
|
65
|
+
): Promise<TResult> => {
|
|
66
|
+
const subject = await createSubject();
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
return await run(subject);
|
|
70
|
+
} finally {
|
|
71
|
+
await subject.dispose?.();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const seedExistingEntity = async <TTable extends AnyStoreTable>(
|
|
76
|
+
options: StoreAccessorContractOptions<TTable>,
|
|
77
|
+
accessor: StoreAccessor<TTable>
|
|
78
|
+
): Promise<EntityOf<TTable>> => {
|
|
79
|
+
if (options.seedExisting !== undefined) {
|
|
80
|
+
return await options.seedExisting(accessor);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return await accessor.upsert(options.createInput());
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Shared contract cases for backend-agnostic writable store accessors.
|
|
88
|
+
*
|
|
89
|
+
* Adapters can register these with their own `test(...)` wrappers so the
|
|
90
|
+
* baseline `get/list/upsert/remove` contract stays aligned across runtimes
|
|
91
|
+
* without fighting repository-specific test-lint rules.
|
|
92
|
+
*/
|
|
93
|
+
export const createStoreAccessorContractCases = <TTable extends AnyStoreTable>(
|
|
94
|
+
options: StoreAccessorContractOptions<TTable>
|
|
95
|
+
): readonly StoreAccessorContractCase[] =>
|
|
96
|
+
[
|
|
97
|
+
{
|
|
98
|
+
name: 'upsert creates an entity and exposes it through get/list',
|
|
99
|
+
run: async () => {
|
|
100
|
+
await withSubject(options.createSubject, async ({ accessor }) => {
|
|
101
|
+
const input = options.createInput();
|
|
102
|
+
const created = await accessor.upsert(input);
|
|
103
|
+
|
|
104
|
+
await options.expectCreated(created, input);
|
|
105
|
+
expect(
|
|
106
|
+
await accessor.get(
|
|
107
|
+
created[
|
|
108
|
+
options.table.identity as keyof EntityOf<TTable> & string
|
|
109
|
+
] as StoreIdentifierOf<TTable>
|
|
110
|
+
)
|
|
111
|
+
).toEqual(created);
|
|
112
|
+
expect(
|
|
113
|
+
await listByIdentity(options.table, accessor, created)
|
|
114
|
+
).toEqual([created]);
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'upsert updates an existing entity in place when the identity matches',
|
|
120
|
+
run: async () => {
|
|
121
|
+
await withSubject(options.createSubject, async ({ accessor }) => {
|
|
122
|
+
const existing = await seedExistingEntity(options, accessor);
|
|
123
|
+
const input = options.updateInput(existing);
|
|
124
|
+
const updated = await accessor.upsert(input);
|
|
125
|
+
const identity = options.table.identity as keyof EntityOf<TTable> &
|
|
126
|
+
string;
|
|
127
|
+
|
|
128
|
+
expect(updated[identity]).toBe(existing[identity]);
|
|
129
|
+
await options.expectUpdated(updated, existing, input);
|
|
130
|
+
expect(
|
|
131
|
+
await accessor.get(updated[identity] as StoreIdentifierOf<TTable>)
|
|
132
|
+
).toEqual(updated);
|
|
133
|
+
expect(
|
|
134
|
+
await listByIdentity(options.table, accessor, updated)
|
|
135
|
+
).toEqual([updated]);
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'remove deletes an existing entity',
|
|
141
|
+
run: async () => {
|
|
142
|
+
await withSubject(options.createSubject, async ({ accessor }) => {
|
|
143
|
+
const existing = await seedExistingEntity(options, accessor);
|
|
144
|
+
const identity = options.table.identity as keyof EntityOf<TTable> &
|
|
145
|
+
string;
|
|
146
|
+
const removed = await accessor.remove(
|
|
147
|
+
existing[identity] as StoreIdentifierOf<TTable>
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(removed).toEqual({ deleted: true });
|
|
151
|
+
expect(
|
|
152
|
+
await accessor.get(existing[identity] as StoreIdentifierOf<TTable>)
|
|
153
|
+
).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'get returns null for a missing identity',
|
|
159
|
+
run: async () => {
|
|
160
|
+
await withSubject(options.createSubject, async ({ accessor }) => {
|
|
161
|
+
expect(await accessor.get(options.missingId)).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: 'remove reports false when the identity is missing',
|
|
167
|
+
run: async () => {
|
|
168
|
+
await withSubject(options.createSubject, async ({ accessor }) => {
|
|
169
|
+
expect(await accessor.remove(options.missingId)).toEqual({
|
|
170
|
+
deleted: false,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
] as const;
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Implementation,
|
|
3
|
+
PermitRequirement,
|
|
4
|
+
Resource,
|
|
5
|
+
Trail,
|
|
6
|
+
} from '@ontrails/core';
|
|
7
|
+
import { deriveTrail } from '@ontrails/core/trails';
|
|
8
|
+
import type {
|
|
9
|
+
DeriveTrailInput,
|
|
10
|
+
DeriveTrailOutput,
|
|
11
|
+
} from '@ontrails/core/trails';
|
|
12
|
+
import type { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
AnyStoreTable,
|
|
16
|
+
EntityOf,
|
|
17
|
+
FiltersOf,
|
|
18
|
+
InsertOf,
|
|
19
|
+
StoreAccessor,
|
|
20
|
+
StoreIdentifierOf,
|
|
21
|
+
UpdateOf,
|
|
22
|
+
} from '../types.js';
|
|
23
|
+
import type { CrudOperation } from '../crud-doctrine.js';
|
|
24
|
+
import { assertCurrentEntityOption, createTableEntity } from './utils.js';
|
|
25
|
+
import type { TableEntity } from './utils.js';
|
|
26
|
+
|
|
27
|
+
type IdentityInputOf<TTable extends AnyStoreTable> = Readonly<
|
|
28
|
+
Record<Extract<TTable['identity'], string>, StoreIdentifierOf<TTable>>
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
type CrudConnection<TTable extends AnyStoreTable> = Readonly<
|
|
32
|
+
Record<TTable['name'], StoreAccessor<TTable>>
|
|
33
|
+
>;
|
|
34
|
+
|
|
35
|
+
type TableEntityFieldKey<TTable extends AnyStoreTable> = Extract<
|
|
36
|
+
keyof z.output<TableEntity<TTable>>,
|
|
37
|
+
string
|
|
38
|
+
>;
|
|
39
|
+
|
|
40
|
+
type GeneratedFieldsOf<TTable extends AnyStoreTable> =
|
|
41
|
+
TTable['generated'] extends readonly TableEntityFieldKey<TTable>[]
|
|
42
|
+
? TTable['generated']
|
|
43
|
+
: readonly [];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Input type `deriveTrail` derives for a given CRUD operation against a
|
|
47
|
+
* store table. Uses `TableEntity<TTable>` so the derived input
|
|
48
|
+
* structurally matches the entity-backed derivation path in
|
|
49
|
+
* `@ontrails/core`'s `deriveTrail`.
|
|
50
|
+
*/
|
|
51
|
+
type DerivedInput<
|
|
52
|
+
TTable extends AnyStoreTable,
|
|
53
|
+
TOperation extends CrudOperation,
|
|
54
|
+
> = DeriveTrailInput<
|
|
55
|
+
TableEntity<TTable>,
|
|
56
|
+
TOperation,
|
|
57
|
+
GeneratedFieldsOf<TTable>
|
|
58
|
+
>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Output type `deriveTrail` derives for a given CRUD operation against a
|
|
62
|
+
* store table.
|
|
63
|
+
*/
|
|
64
|
+
type DerivedOutput<
|
|
65
|
+
TTable extends AnyStoreTable,
|
|
66
|
+
TOperation extends CrudOperation,
|
|
67
|
+
> = DeriveTrailOutput<TableEntity<TTable>, TOperation>;
|
|
68
|
+
|
|
69
|
+
type InternalCreateTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
70
|
+
DerivedInput<TTable, 'create'>,
|
|
71
|
+
DerivedOutput<TTable, 'create'>
|
|
72
|
+
>;
|
|
73
|
+
|
|
74
|
+
type InternalReadTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
75
|
+
DerivedInput<TTable, 'read'>,
|
|
76
|
+
DerivedOutput<TTable, 'read'>
|
|
77
|
+
>;
|
|
78
|
+
|
|
79
|
+
type InternalUpdateTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
80
|
+
DerivedInput<TTable, 'update'>,
|
|
81
|
+
DerivedOutput<TTable, 'update'>
|
|
82
|
+
>;
|
|
83
|
+
|
|
84
|
+
type InternalDeleteTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
85
|
+
DerivedInput<TTable, 'delete'>,
|
|
86
|
+
DerivedOutput<TTable, 'delete'>
|
|
87
|
+
>;
|
|
88
|
+
|
|
89
|
+
type InternalListTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
90
|
+
DerivedInput<TTable, 'list'>,
|
|
91
|
+
DerivedOutput<TTable, 'list'>
|
|
92
|
+
>;
|
|
93
|
+
|
|
94
|
+
type InternalCrudBaseTrails<TTable extends AnyStoreTable> = Readonly<{
|
|
95
|
+
createBase: InternalCreateTrailOf<TTable>;
|
|
96
|
+
readBase: InternalReadTrailOf<TTable>;
|
|
97
|
+
updateBase: InternalUpdateTrailOf<TTable>;
|
|
98
|
+
deleteBase: InternalDeleteTrailOf<TTable>;
|
|
99
|
+
listBase: InternalListTrailOf<TTable>;
|
|
100
|
+
}>;
|
|
101
|
+
|
|
102
|
+
type TrailExampleOf<TInput, TOutput> = NonNullable<
|
|
103
|
+
Trail<TInput, TOutput>['examples']
|
|
104
|
+
>[number];
|
|
105
|
+
|
|
106
|
+
type CreateTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
107
|
+
InsertOf<TTable>,
|
|
108
|
+
EntityOf<TTable>
|
|
109
|
+
>;
|
|
110
|
+
|
|
111
|
+
type ReadTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
112
|
+
IdentityInputOf<TTable>,
|
|
113
|
+
EntityOf<TTable>
|
|
114
|
+
>;
|
|
115
|
+
|
|
116
|
+
type UpdateTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
117
|
+
IdentityInputOf<TTable> & UpdateOf<TTable>,
|
|
118
|
+
EntityOf<TTable>
|
|
119
|
+
>;
|
|
120
|
+
|
|
121
|
+
type DeleteTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
122
|
+
IdentityInputOf<TTable>,
|
|
123
|
+
undefined
|
|
124
|
+
>;
|
|
125
|
+
|
|
126
|
+
type ListTrailOf<TTable extends AnyStoreTable> = Trail<
|
|
127
|
+
FiltersOf<TTable>,
|
|
128
|
+
EntityOf<TTable>[]
|
|
129
|
+
>;
|
|
130
|
+
|
|
131
|
+
type InternalCrudTrails<TTable extends AnyStoreTable> = readonly [
|
|
132
|
+
create: InternalCreateTrailOf<TTable>,
|
|
133
|
+
read: InternalReadTrailOf<TTable>,
|
|
134
|
+
update: InternalUpdateTrailOf<TTable>,
|
|
135
|
+
remove: InternalDeleteTrailOf<TTable>,
|
|
136
|
+
list: InternalListTrailOf<TTable>,
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
export type CrudTrails<TTable extends AnyStoreTable> = readonly [
|
|
140
|
+
create: CreateTrailOf<TTable>,
|
|
141
|
+
read: ReadTrailOf<TTable>,
|
|
142
|
+
update: UpdateTrailOf<TTable>,
|
|
143
|
+
remove: DeleteTrailOf<TTable>,
|
|
144
|
+
list: ListTrailOf<TTable>,
|
|
145
|
+
] & {
|
|
146
|
+
/**
|
|
147
|
+
* The table entity the factory registered on its trails. Pass it to
|
|
148
|
+
* `reconcile({ entity })` (or other factories over the same table) so
|
|
149
|
+
* the topo sees one shared entity instance instead of rejecting two
|
|
150
|
+
* same-named rebuilds as duplicates.
|
|
151
|
+
*/
|
|
152
|
+
readonly entity: TableEntity<TTable>;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export interface CrudImplementationOverrides<TTable extends AnyStoreTable> {
|
|
156
|
+
readonly create?: Implementation<InsertOf<TTable>, EntityOf<TTable>>;
|
|
157
|
+
readonly read?: Implementation<IdentityInputOf<TTable>, EntityOf<TTable>>;
|
|
158
|
+
readonly update?: Implementation<
|
|
159
|
+
IdentityInputOf<TTable> & UpdateOf<TTable>,
|
|
160
|
+
EntityOf<TTable>
|
|
161
|
+
>;
|
|
162
|
+
readonly delete?: Implementation<IdentityInputOf<TTable>, undefined>;
|
|
163
|
+
readonly list?: Implementation<FiltersOf<TTable>, EntityOf<TTable>[]>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface CrudOptions<TTable extends AnyStoreTable> {
|
|
167
|
+
readonly implementation?: CrudImplementationOverrides<TTable>;
|
|
168
|
+
/**
|
|
169
|
+
* Existing table entity to register on the produced trails. When
|
|
170
|
+
* omitted, the factory builds one from the table. Pass a shared
|
|
171
|
+
* instance when another factory (e.g. `reconcile()`) covers the same
|
|
172
|
+
* table so `topo()` sees a single entity registration.
|
|
173
|
+
*/
|
|
174
|
+
readonly entity?: TableEntity<TTable>;
|
|
175
|
+
/**
|
|
176
|
+
* Permit requirement declared on every produced trail. Factory trails
|
|
177
|
+
* carry authored defaults like any hand-written trail; per-operation
|
|
178
|
+
* entries in `permits` override this baseline.
|
|
179
|
+
*/
|
|
180
|
+
readonly permit?: PermitRequirement;
|
|
181
|
+
/**
|
|
182
|
+
* Per-operation permit overrides. At minimum, destroy-intent trails
|
|
183
|
+
* (`delete`) need a declaration to satisfy permit governance.
|
|
184
|
+
*/
|
|
185
|
+
readonly permits?: Partial<Record<CrudOperation, PermitRequirement>>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface InternalCrudImplementationOverrides<TTable extends AnyStoreTable> {
|
|
189
|
+
readonly create?: Implementation<
|
|
190
|
+
DerivedInput<TTable, 'create'>,
|
|
191
|
+
DerivedOutput<TTable, 'create'>
|
|
192
|
+
>;
|
|
193
|
+
readonly read?: Implementation<
|
|
194
|
+
DerivedInput<TTable, 'read'>,
|
|
195
|
+
DerivedOutput<TTable, 'read'>
|
|
196
|
+
>;
|
|
197
|
+
readonly update?: Implementation<
|
|
198
|
+
DerivedInput<TTable, 'update'>,
|
|
199
|
+
DerivedOutput<TTable, 'update'>
|
|
200
|
+
>;
|
|
201
|
+
readonly delete?: Implementation<
|
|
202
|
+
DerivedInput<TTable, 'delete'>,
|
|
203
|
+
DerivedOutput<TTable, 'delete'>
|
|
204
|
+
>;
|
|
205
|
+
readonly list?: Implementation<
|
|
206
|
+
DerivedInput<TTable, 'list'>,
|
|
207
|
+
DerivedOutput<TTable, 'list'>
|
|
208
|
+
>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface InternalCrudOptions<TTable extends AnyStoreTable> {
|
|
212
|
+
readonly implementation?: InternalCrudImplementationOverrides<TTable>;
|
|
213
|
+
readonly entity?: TableEntity<TTable>;
|
|
214
|
+
readonly permit?: PermitRequirement;
|
|
215
|
+
readonly permits?: Partial<Record<CrudOperation, PermitRequirement>>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const normalizeExampleForOutput = <TInput, TOutput>(
|
|
219
|
+
example: TrailExampleOf<TInput, TOutput>,
|
|
220
|
+
output: z.ZodType<TOutput>
|
|
221
|
+
): TrailExampleOf<TInput, TOutput> | undefined => {
|
|
222
|
+
if (example.expected === undefined) {
|
|
223
|
+
return example;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const parsed = output.safeParse(example.expected);
|
|
227
|
+
return parsed.success
|
|
228
|
+
? {
|
|
229
|
+
...example,
|
|
230
|
+
expected: parsed.data,
|
|
231
|
+
}
|
|
232
|
+
: undefined;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const normalizeExamplesForOutput = <TInput, TOutput>(
|
|
236
|
+
base: Trail<TInput, TOutput>,
|
|
237
|
+
output: z.ZodType<TOutput>
|
|
238
|
+
): Trail<TInput, TOutput>['examples'] => {
|
|
239
|
+
const { examples } = base;
|
|
240
|
+
if (examples === undefined || examples.length === 0) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const next = examples
|
|
245
|
+
.map((example) => normalizeExampleForOutput(example, output))
|
|
246
|
+
.filter(
|
|
247
|
+
(example): example is TrailExampleOf<TInput, TOutput> =>
|
|
248
|
+
example !== undefined
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
return next.length === 0
|
|
252
|
+
? undefined
|
|
253
|
+
: (Object.freeze(next) as Trail<TInput, TOutput>['examples']);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const finalizeTrail = <TInput, TOutput>(
|
|
257
|
+
base: Trail<TInput, TOutput>,
|
|
258
|
+
options: {
|
|
259
|
+
readonly implementation?: Implementation<TInput, TOutput> | undefined;
|
|
260
|
+
readonly output?: z.ZodType<TOutput> | undefined;
|
|
261
|
+
readonly pattern?: string | undefined;
|
|
262
|
+
readonly permit?: PermitRequirement | undefined;
|
|
263
|
+
} = {}
|
|
264
|
+
): Trail<TInput, TOutput> =>
|
|
265
|
+
Object.freeze({
|
|
266
|
+
...base,
|
|
267
|
+
...(options.implementation === undefined
|
|
268
|
+
? {}
|
|
269
|
+
: { implementation: options.implementation }),
|
|
270
|
+
...(options.output === undefined
|
|
271
|
+
? {}
|
|
272
|
+
: {
|
|
273
|
+
examples: normalizeExamplesForOutput(base, options.output),
|
|
274
|
+
output: options.output,
|
|
275
|
+
}),
|
|
276
|
+
...(options.pattern === undefined ? {} : { pattern: options.pattern }),
|
|
277
|
+
...(options.permit === undefined ? {} : { permit: options.permit }),
|
|
278
|
+
}) as Trail<TInput, TOutput>;
|
|
279
|
+
|
|
280
|
+
const deriveCrudBaseTrails = <
|
|
281
|
+
TTable extends AnyStoreTable,
|
|
282
|
+
TConnection extends CrudConnection<TTable>,
|
|
283
|
+
>(
|
|
284
|
+
table: TTable,
|
|
285
|
+
resource: Resource<TConnection>,
|
|
286
|
+
tableEntity: TableEntity<TTable>
|
|
287
|
+
): InternalCrudBaseTrails<TTable> => {
|
|
288
|
+
// Narrow the store's `readonly string[]` to the entity's typed field-key
|
|
289
|
+
// array so `deriveTrail`'s `TGenerated` generic picks up the precise
|
|
290
|
+
// key-of shape that `CreateInputOf<Entity, TGenerated>` expects. The
|
|
291
|
+
// runtime value is unchanged — the names in `table.generated` are already
|
|
292
|
+
// keys of `table.schema.shape` by construction in `store()`.
|
|
293
|
+
const generated = table.generated as GeneratedFieldsOf<TTable>;
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
createBase: deriveTrail(tableEntity, 'create', {
|
|
297
|
+
generated,
|
|
298
|
+
resource,
|
|
299
|
+
}),
|
|
300
|
+
deleteBase: deriveTrail(tableEntity, 'delete', {
|
|
301
|
+
resource,
|
|
302
|
+
}),
|
|
303
|
+
listBase: deriveTrail(tableEntity, 'list', {
|
|
304
|
+
resource,
|
|
305
|
+
}),
|
|
306
|
+
readBase: deriveTrail(tableEntity, 'read', {
|
|
307
|
+
resource,
|
|
308
|
+
}),
|
|
309
|
+
// The `update` implementation synthesized by `deriveTrail` handles the partial-patch
|
|
310
|
+
// concern: when the accessor lacks a native `update`, the fallback path in
|
|
311
|
+
// `derive-trail.ts` (`updateViaReadAndUpsert`) reads the current entity,
|
|
312
|
+
// merges the patch, strips the `version` field, then calls `upsert` with
|
|
313
|
+
// the full merged payload — so no fields are silently lost.
|
|
314
|
+
updateBase: deriveTrail(tableEntity, 'update', {
|
|
315
|
+
generated,
|
|
316
|
+
resource,
|
|
317
|
+
}),
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const buildCrudTrails = <TTable extends AnyStoreTable>(
|
|
322
|
+
baseTrails: InternalCrudBaseTrails<TTable>,
|
|
323
|
+
options: InternalCrudOptions<TTable>,
|
|
324
|
+
entityOutput: z.ZodType<DerivedOutput<TTable, 'create'>>,
|
|
325
|
+
listOutput: z.ZodType<DerivedOutput<TTable, 'list'>>
|
|
326
|
+
): InternalCrudTrails<TTable> => {
|
|
327
|
+
const overrides = options.implementation ?? {};
|
|
328
|
+
const permitFor = (operation: CrudOperation): PermitRequirement | undefined =>
|
|
329
|
+
options.permits?.[operation] ?? options.permit;
|
|
330
|
+
|
|
331
|
+
return Object.freeze([
|
|
332
|
+
finalizeTrail(baseTrails.createBase, {
|
|
333
|
+
...(overrides.create === undefined
|
|
334
|
+
? {}
|
|
335
|
+
: { implementation: overrides.create }),
|
|
336
|
+
output: entityOutput,
|
|
337
|
+
pattern: 'crud',
|
|
338
|
+
permit: permitFor('create'),
|
|
339
|
+
}),
|
|
340
|
+
finalizeTrail(baseTrails.readBase, {
|
|
341
|
+
...(overrides.read === undefined
|
|
342
|
+
? {}
|
|
343
|
+
: { implementation: overrides.read }),
|
|
344
|
+
output: entityOutput,
|
|
345
|
+
pattern: 'crud',
|
|
346
|
+
permit: permitFor('read'),
|
|
347
|
+
}),
|
|
348
|
+
finalizeTrail(baseTrails.updateBase, {
|
|
349
|
+
...(overrides.update === undefined
|
|
350
|
+
? {}
|
|
351
|
+
: { implementation: overrides.update }),
|
|
352
|
+
output: entityOutput,
|
|
353
|
+
pattern: 'crud',
|
|
354
|
+
permit: permitFor('update'),
|
|
355
|
+
}),
|
|
356
|
+
overrides.delete === undefined
|
|
357
|
+
? finalizeTrail(baseTrails.deleteBase, {
|
|
358
|
+
pattern: 'crud',
|
|
359
|
+
permit: permitFor('delete'),
|
|
360
|
+
})
|
|
361
|
+
: finalizeTrail(baseTrails.deleteBase, {
|
|
362
|
+
implementation: overrides.delete,
|
|
363
|
+
pattern: 'crud',
|
|
364
|
+
permit: permitFor('delete'),
|
|
365
|
+
}),
|
|
366
|
+
finalizeTrail(baseTrails.listBase, {
|
|
367
|
+
...(overrides.list === undefined
|
|
368
|
+
? {}
|
|
369
|
+
: { implementation: overrides.list }),
|
|
370
|
+
output: listOutput,
|
|
371
|
+
pattern: 'crud',
|
|
372
|
+
permit: permitFor('list'),
|
|
373
|
+
}),
|
|
374
|
+
]) as InternalCrudTrails<TTable>;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Produce the standard CRUD trail tuple for one normalized store table.
|
|
379
|
+
*
|
|
380
|
+
* The factory derives schemas, examples, resources, and entity linkage from
|
|
381
|
+
* the table metadata. Implementations default to the backend-agnostic store accessor
|
|
382
|
+
* contract via `deriveTrail()`'s single-resource synthesis path. Per-operation
|
|
383
|
+
* implementation overrides stay available for callers that need custom persistence
|
|
384
|
+
* behavior and are layered onto the derived trails in a single pass.
|
|
385
|
+
*/
|
|
386
|
+
export function crud<
|
|
387
|
+
TTable extends AnyStoreTable,
|
|
388
|
+
TConnection extends CrudConnection<TTable>,
|
|
389
|
+
>(
|
|
390
|
+
table: TTable,
|
|
391
|
+
resource: Resource<TConnection>,
|
|
392
|
+
options?: CrudOptions<TTable>
|
|
393
|
+
): CrudTrails<TTable>;
|
|
394
|
+
export function crud<
|
|
395
|
+
TTable extends AnyStoreTable,
|
|
396
|
+
TConnection extends CrudConnection<TTable>,
|
|
397
|
+
>(
|
|
398
|
+
table: TTable,
|
|
399
|
+
resource: Resource<TConnection>,
|
|
400
|
+
options: InternalCrudOptions<TTable> = {}
|
|
401
|
+
) {
|
|
402
|
+
assertCurrentEntityOption(options, 'crud() options');
|
|
403
|
+
const tableEntity = options.entity ?? createTableEntity(table);
|
|
404
|
+
const baseTrails = deriveCrudBaseTrails(table, resource, tableEntity);
|
|
405
|
+
// Narrow `table.schema` (typed `StoreObjectSchema`, which is
|
|
406
|
+
// `z.ZodObject<Record<string, z.ZodType>>`) to a ZodObject keyed by the
|
|
407
|
+
// concrete shape so its `z.output` unifies with the entity-derived
|
|
408
|
+
// output. Structurally `table.schema` already has `shape:
|
|
409
|
+
// TTable['schema']['shape']` — this only refines the generic parameter.
|
|
410
|
+
const entitySchema = table.schema as z.ZodObject<TTable['schema']['shape']>;
|
|
411
|
+
const entityOutput: z.ZodType<DerivedOutput<TTable, 'create'>> = entitySchema;
|
|
412
|
+
const listOutput: z.ZodType<DerivedOutput<TTable, 'list'>> =
|
|
413
|
+
entitySchema.array();
|
|
414
|
+
|
|
415
|
+
const trails = buildCrudTrails(baseTrails, options, entityOutput, listOutput);
|
|
416
|
+
// Expose the registered entity so other factories over the same table
|
|
417
|
+
// (reconcile, sync) can share the instance instead of rebuilding it.
|
|
418
|
+
return Object.freeze(
|
|
419
|
+
Object.assign([...trails], { entity: tableEntity })
|
|
420
|
+
) as unknown as InternalCrudTrails<TTable> & {
|
|
421
|
+
readonly entity: TableEntity<TTable>;
|
|
422
|
+
};
|
|
423
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { crudAccessorExpectations, crudOperations } from '../crud-doctrine.js';
|
|
2
|
+
export type {
|
|
3
|
+
CrudAccessorExpectation,
|
|
4
|
+
CrudOperation,
|
|
5
|
+
} from '../crud-doctrine.js';
|
|
6
|
+
export { crud } from './crud.js';
|
|
7
|
+
export type {
|
|
8
|
+
CrudImplementationOverrides,
|
|
9
|
+
CrudOptions,
|
|
10
|
+
CrudTrails,
|
|
11
|
+
} from './crud.js';
|
|
12
|
+
export { reconcile } from './reconcile.js';
|
|
13
|
+
export type {
|
|
14
|
+
ReconcileConflict,
|
|
15
|
+
ReconcileOptions,
|
|
16
|
+
ReconcileStrategy,
|
|
17
|
+
} from './reconcile.js';
|
|
18
|
+
export { sync } from './sync.js';
|
|
19
|
+
export type { SyncEndpoint, SyncOptions, SyncTransform } from './sync.js';
|
|
20
|
+
export type { TableEntity } from './utils.js';
|