@fougere/core 0.12.0-alpha.0 → 0.12.1-alpha.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/dist/EffectiveOperationModel.d.ts.map +1 -1
- package/dist/EffectiveOperationModel.js +18 -0
- package/dist/EffectiveOperationModel.js.map +1 -1
- package/dist/boot/install.d.ts.map +1 -1
- package/dist/boot/install.js +5 -1
- package/dist/boot/install.js.map +1 -1
- package/dist/boot/remote.d.ts.map +1 -1
- package/dist/boot/remote.js +8 -2
- package/dist/boot/remote.js.map +1 -1
- package/dist/contract.d.ts +2 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -1
- package/dist/dispatch/OutputView.d.ts.map +1 -1
- package/dist/dispatch/OutputView.js +7 -0
- package/dist/dispatch/OutputView.js.map +1 -1
- package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
- package/dist/dispatch/PresenterExecutor.js +7 -0
- package/dist/dispatch/PresenterExecutor.js.map +1 -1
- package/dist/dispatch/Received.d.ts +3 -0
- package/dist/dispatch/Received.d.ts.map +1 -0
- package/dist/dispatch/Received.js +2 -0
- package/dist/dispatch/Received.js.map +1 -0
- package/dist/dispatch/decoded.d.ts +19 -0
- package/dist/dispatch/decoded.d.ts.map +1 -0
- package/dist/dispatch/decoded.js +36 -0
- package/dist/dispatch/decoded.js.map +1 -0
- package/dist/entry/facade.d.ts +9 -2
- package/dist/entry/facade.d.ts.map +1 -1
- package/dist/entry/facade.js +16 -7
- package/dist/entry/facade.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/prefab/CrudConstructor.d.ts.map +1 -1
- package/dist/prefab/CrudConstructor.js +3 -2
- package/dist/prefab/CrudConstructor.js.map +1 -1
- package/dist/prefab/CrudOps.d.ts +2 -2
- package/dist/prefab/CrudOps.d.ts.map +1 -1
- package/dist/prefab/MirrorConstructor.js +1 -1
- package/dist/prefab/MirrorConstructor.js.map +1 -1
- package/dist/prefab/Refreshed.d.ts +7 -2
- package/dist/prefab/Refreshed.d.ts.map +1 -1
- package/dist/storage/Store.d.ts.map +1 -1
- package/dist/storage/Store.js +6 -10
- package/dist/storage/Store.js.map +1 -1
- package/dist/wire/AppMiddleware.d.ts +10 -1
- package/dist/wire/AppMiddleware.d.ts.map +1 -1
- package/dist/wire/AppMiddleware.js.map +1 -1
- package/dist/wire/AppNext.d.ts +2 -1
- package/dist/wire/AppNext.d.ts.map +1 -1
- package/dist/wire/Facade.d.ts +14 -2
- package/dist/wire/Facade.d.ts.map +1 -1
- package/dist/wire/Facade.js.map +1 -1
- package/dist/wire/FougereError.js +1 -1
- package/dist/wire/FougereError.js.map +1 -1
- package/dist/wire/OperationContract.js +1 -1
- package/dist/wire/OperationContract.js.map +1 -1
- package/dist/wire/Page.d.ts +32 -0
- package/dist/wire/Page.d.ts.map +1 -0
- package/dist/wire/Page.js +32 -0
- package/dist/wire/Page.js.map +1 -0
- package/dist/wire/Signature.d.ts +7 -0
- package/dist/wire/Signature.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/EffectiveOperationModel.ts +19 -0
- package/src/boot/install.ts +4 -0
- package/src/boot/remote.ts +9 -2
- package/src/contract.ts +2 -0
- package/src/dispatch/OutputView.ts +7 -0
- package/src/dispatch/PresenterExecutor.ts +7 -0
- package/src/dispatch/Received.ts +2 -0
- package/src/dispatch/decoded.ts +37 -0
- package/src/entry/facade.ts +21 -9
- package/src/index.ts +1 -0
- package/src/prefab/CrudConstructor.ts +3 -2
- package/src/prefab/CrudOps.ts +2 -2
- package/src/prefab/MirrorConstructor.ts +1 -1
- package/src/prefab/Refreshed.ts +7 -2
- package/src/storage/Store.ts +6 -9
- package/src/wire/AppMiddleware.ts +10 -1
- package/src/wire/AppNext.ts +2 -1
- package/src/wire/Facade.ts +14 -2
- package/src/wire/FougereError.ts +1 -1
- package/src/wire/OperationContract.ts +1 -1
- package/src/wire/Page.ts +50 -0
- package/src/wire/Signature.ts +7 -0
|
@@ -117,6 +117,25 @@ export function resolveEffectiveOperations(
|
|
|
117
117
|
const contract = normalizeBinding(rawContract, handler, frond, name, resolutionDiagnostics);
|
|
118
118
|
if (!contract) continue;
|
|
119
119
|
|
|
120
|
+
// An operation answers DATA — what JSON keeps. A DECLARED output is converted by its
|
|
121
|
+
// fields, so `created()` leaves as an ISO string and nothing is asked of it here; an
|
|
122
|
+
// undeclared one has nothing to convert it, so it must already be data. Measured
|
|
123
|
+
// 2026-09-18: `{ at: new Date(0) }` reaches a local caller as a `Date` and a remote one
|
|
124
|
+
// as a string, and an object carrying methods loses them on both sides, differently.
|
|
125
|
+
if (!contract.output && contract.signature?.notData) {
|
|
126
|
+
resolutionDiagnostics.push({
|
|
127
|
+
severity: 'blocking',
|
|
128
|
+
code: 'operation-output-not-data',
|
|
129
|
+
filePath: handler.filePath,
|
|
130
|
+
frond: frond.name,
|
|
131
|
+
subject,
|
|
132
|
+
message: `${subject}() answers ${contract.signature.notData}, which carries methods.\n`
|
|
133
|
+
+ ' An operation answers data — what JSON keeps.\n'
|
|
134
|
+
+ ' Declare the output as an entity, whose fields convert it, or answer what it produces.',
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
const override = frond.operationsOverrides?.[name];
|
|
121
140
|
const inference = inferOperationKind(name);
|
|
122
141
|
const inferredKinds = new Set<OperationKind>();
|
package/src/boot/install.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { RouteAddress } from '../wire/RouteAddress.js';
|
|
|
37
37
|
import { servedSurfaces } from '../descriptor/surface.js';
|
|
38
38
|
import { OperationRoute } from '../dispatch/OperationRoute.js';
|
|
39
39
|
import { facadeOperations } from '../entry/facade.js';
|
|
40
|
+
import { decoded } from '../dispatch/decoded.js';
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* What the app is made of while it is still being made. `createApp` builds these, hands
|
|
@@ -449,6 +450,9 @@ function buildFacadeInto(
|
|
|
449
450
|
handler.address,
|
|
450
451
|
routeRegistry.operationNames(handler.address, handler.surface),
|
|
451
452
|
handler.surface,
|
|
453
|
+
// The output SCHEMA, per op: `Crud(Post, { list: PostCard })` names a different one for
|
|
454
|
+
// one operation, so the answer is read back through the view that op actually serves.
|
|
455
|
+
(operation, answer) => decoded(facade.effectiveOperations.get(operation)?.output, answer),
|
|
452
456
|
));
|
|
453
457
|
}
|
|
454
458
|
|
package/src/boot/remote.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { ErrorCode } from '../wire/ErrorCode.js';
|
|
|
15
15
|
import { FougereError } from '../wire/FougereError.js';
|
|
16
16
|
import { Card, type SchemaView, type SchemaDescriptor } from '@fougere/schema';
|
|
17
17
|
import { dynamicOperations } from '../entry/facade.js';
|
|
18
|
+
import { decoded } from '../dispatch/decoded.js';
|
|
18
19
|
|
|
19
20
|
interface Route {
|
|
20
21
|
frond: string;
|
|
@@ -103,13 +104,19 @@ export function createRemoteFacade(
|
|
|
103
104
|
middlewaresFor: (address: string) => AppMiddleware[],
|
|
104
105
|
): Facade {
|
|
105
106
|
const opFn = (op: string) => async (invocation: InvocationContext = Invocation.empty) => {
|
|
106
|
-
const { frond, transport } = await router.route(entity);
|
|
107
|
+
const { frond, transport, schema } = await router.route(entity);
|
|
107
108
|
const call: FrondCall = { frond, entity, op };
|
|
108
109
|
const ctx: OperationContext = {
|
|
109
110
|
entity, frond, operation: op, args: [], state: invocation.state, invocation,
|
|
110
111
|
};
|
|
111
|
-
|
|
112
|
+
const answer = await runMiddlewares(middlewaresFor(entity), ctx, () =>
|
|
112
113
|
transport(call, ctx.invocation ?? invocation));
|
|
114
|
+
|
|
115
|
+
// The schema the card carried, put to work: a row crosses as data and comes back through
|
|
116
|
+
// the same codecs a local facade applies, so a placement does not decide what a caller
|
|
117
|
+
// holds. It is the ENTITY's — an op serving a narrower view is the far side's business,
|
|
118
|
+
// and what this card names is the shape it publishes.
|
|
119
|
+
return decoded(schema, answer);
|
|
113
120
|
};
|
|
114
121
|
|
|
115
122
|
return dynamicOperations(opFn) as Facade;
|
package/src/contract.ts
CHANGED
|
@@ -29,6 +29,7 @@ export { MAX_BODY_BYTES } from './wire/SignedCall.js';
|
|
|
29
29
|
// call log ignoring its own reader — has to be able to name it.
|
|
30
30
|
export { RPC_ENTITY } from './wire/RpcAnswer.js';
|
|
31
31
|
export type { CallPage } from './wire/CallPage.js';
|
|
32
|
+
export { pageOf, asPage, type Page } from './wire/Page.js';
|
|
32
33
|
export type { CallRecord } from './wire/CallRecord.js';
|
|
33
34
|
// The comparison of two cards, which a consumer runs about a producer — browser-safe on
|
|
34
35
|
// purpose: a panel showing the drift holds only the two cards, never the app.
|
|
@@ -55,6 +56,7 @@ export type { Edge } from './wire/topology/Edge.js';
|
|
|
55
56
|
export type { FrondPlacement } from './wire/topology/FrondPlacement.js';
|
|
56
57
|
export type { TopologyReport } from './wire/topology/TopologyReport.js';
|
|
57
58
|
export { assertIdentityCard } from './wire/card/IdentityCard.js';
|
|
59
|
+
export { decoded } from './dispatch/decoded.js';
|
|
58
60
|
|
|
59
61
|
/** The key a class name is filed under — 'Post' → 'post'. */
|
|
60
62
|
export { lowerFirst } from '@fougere/schema';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Visibility, type Fields } from '@fougere/schema';
|
|
2
2
|
import { preserveArrayProperties } from './ArrayResult.js';
|
|
3
|
+
import { asPage } from '../wire/Page.js';
|
|
3
4
|
|
|
4
5
|
/** What one operation result may show. One of the two exits a validator watches. */
|
|
5
6
|
export class OutputView {
|
|
@@ -15,6 +16,12 @@ export class OutputView {
|
|
|
15
16
|
return preserveArrayProperties(result, result.map((item) => this.project(item)));
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
// A paged op answers an envelope, so what a view applies to is its ROWS. Projecting the
|
|
20
|
+
// envelope itself let every field of every row through — `Visibility.encode` finds none of
|
|
21
|
+
// its own keys on `{ items, total }` and hands the object back.
|
|
22
|
+
const page = asPage(result, this.fields);
|
|
23
|
+
if (page) return { ...page, items: page.items.map((item) => this.project(item)) };
|
|
24
|
+
|
|
18
25
|
if (typeof result !== 'object') return result;
|
|
19
26
|
|
|
20
27
|
const record = result as Record<string, unknown>;
|
|
@@ -2,6 +2,7 @@ import { ErrorCode } from '../wire/ErrorCode.js';
|
|
|
2
2
|
import { FougereError } from '../wire/FougereError.js';
|
|
3
3
|
import { preserveArrayProperties } from './ArrayResult.js';
|
|
4
4
|
import type { PresenterArgs } from './PresenterArgs.js';
|
|
5
|
+
import { asPage } from '../wire/Page.js';
|
|
5
6
|
|
|
6
7
|
/** Adds a presenter's computed fields after output projection. */
|
|
7
8
|
export class PresenterExecutor {
|
|
@@ -17,6 +18,12 @@ export class PresenterExecutor {
|
|
|
17
18
|
return result;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// A paged op answers an envelope, and what a computed field sits on is a ROW. Handed the
|
|
22
|
+
// envelope itself, a presenter added its fields to `{ items, total }` and every row came
|
|
23
|
+
// back untouched.
|
|
24
|
+
const page = asPage(result, Object.fromEntries(this.fieldNames.map((name) => [name, true])));
|
|
25
|
+
if (page) return { ...page, items: await this.present(page.items, args) as unknown[] };
|
|
26
|
+
|
|
20
27
|
const rows = Array.isArray(result) ? result : [result];
|
|
21
28
|
const values = new Map<string, unknown[]>();
|
|
22
29
|
for (const name of this.fieldNames) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Visibility, type Fields, type SchemaView } from '@fougere/schema';
|
|
2
|
+
import { preserveArrayProperties } from './ArrayResult.js';
|
|
3
|
+
import { asPage } from '../wire/Page.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a caller receives, read back into the shape its type promises — the dual of
|
|
7
|
+
* `OutputView.project`, applied on the other side of the call.
|
|
8
|
+
*
|
|
9
|
+
* `date-time` means a `Date` on both sides (`Boundary.forShape`), and only the outgoing half was
|
|
10
|
+
* ever applied: `Visibility.encode` turned `new Date(0)` into `"1970-01-01T00:00:00.000Z"` and
|
|
11
|
+
* nobody turned it back, so `Facade<PostHandler>` promised `createdAt: Date` and handed over a
|
|
12
|
+
* string — in this process as well as across a wire.
|
|
13
|
+
*
|
|
14
|
+
* Applied by the facade a caller HOLDS, never by the one that answers: a row leaves as data,
|
|
15
|
+
* which is what a Rust frond or a plain HTTP client reads, and the codecs are what this side
|
|
16
|
+
* knows how to put back. Measured 2026-09-18: 389 ns per row against the 1 277 ns encoding one
|
|
17
|
+
* already costs.
|
|
18
|
+
*
|
|
19
|
+
* Documented: [the gradient](https://fougere.dev/docs/infra/gradient).
|
|
20
|
+
*/
|
|
21
|
+
export function decoded(schema: SchemaView | undefined, answer: unknown): unknown {
|
|
22
|
+
if (!schema || answer === null || answer === undefined) return answer;
|
|
23
|
+
|
|
24
|
+
const fields = schema.getFields() as Fields;
|
|
25
|
+
const visibility = Visibility.of(fields);
|
|
26
|
+
const row = (one: unknown) => (one !== null && typeof one === 'object' && !Array.isArray(one)
|
|
27
|
+
? visibility.decode(one as Record<string, unknown>)
|
|
28
|
+
: one);
|
|
29
|
+
|
|
30
|
+
const page = asPage(answer, fields);
|
|
31
|
+
if (page) return { ...page, items: page.items.map(row) };
|
|
32
|
+
|
|
33
|
+
// An op annotating `ListResult<T>` still answers the array those properties ride on.
|
|
34
|
+
return Array.isArray(answer)
|
|
35
|
+
? preserveArrayProperties(answer, answer.map(row))
|
|
36
|
+
: row(answer);
|
|
37
|
+
}
|
package/src/entry/facade.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Call } from '../wire/Call.js';
|
|
|
2
2
|
|
|
3
3
|
import { RouteAddress } from '../wire/RouteAddress.js';
|
|
4
4
|
import type { DispatchPort } from '../dispatch/DispatchPort.js';
|
|
5
|
+
import type { Received } from '../dispatch/Received.js';
|
|
5
6
|
|
|
6
7
|
type Operation = (...args: any[]) => unknown;
|
|
7
8
|
|
|
@@ -22,21 +23,32 @@ export function dynamicOperations(operation: (name: string) => Operation): Recor
|
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Turns facade method calls into canonical dispatches.
|
|
28
|
+
*
|
|
29
|
+
* `received` is what this side puts back before handing the answer over: a row leaves as data
|
|
30
|
+
* and `date-time` means a `Date` on both sides. A facade built without one hands over what the
|
|
31
|
+
* wire carried, which is what a caller holding no schema can do.
|
|
32
|
+
*/
|
|
26
33
|
export function facadeOperations(
|
|
27
34
|
dispatcher: DispatchPort,
|
|
28
35
|
entity: string,
|
|
29
36
|
operationNames?: Iterable<string>,
|
|
30
37
|
surface?: string,
|
|
38
|
+
received?: Received,
|
|
31
39
|
): Record<string, Operation> {
|
|
32
|
-
const operation = (name: string): Operation => (invocation) =>
|
|
33
|
-
new
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
const operation = (name: string): Operation => async (invocation) => {
|
|
41
|
+
const answer = await dispatcher.dispatch(new Call(
|
|
42
|
+
new RouteAddress({
|
|
43
|
+
entity,
|
|
44
|
+
operation: name,
|
|
45
|
+
...(surface !== undefined ? { surface } : {}),
|
|
46
|
+
}),
|
|
47
|
+
invocation,
|
|
48
|
+
));
|
|
49
|
+
|
|
50
|
+
return received ? received(name, answer) : answer;
|
|
51
|
+
};
|
|
40
52
|
|
|
41
53
|
return operationNames
|
|
42
54
|
? Object.fromEntries([...operationNames].map((name) => [name, operation(name)]))
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,7 @@ export { JOURNAL, type Journal } from './dispatch/Journal.js';
|
|
|
49
49
|
// them — or a test of one — has to be able to make one through the facade.
|
|
50
50
|
export { DispatchEvent } from './dispatch/DispatchEvent.js';
|
|
51
51
|
export type { CallPage, CallRecord } from './contract.js';
|
|
52
|
+
export { pageOf, asPage, type Page } from './contract.js';
|
|
52
53
|
export { driftOf, agrees, explain, type CardDrift } from './contract.js';
|
|
53
54
|
export type { OperationContract } from './wire/OperationContract.js';
|
|
54
55
|
export type { OperationsMap } from './wire/OperationsMap.js';
|
|
@@ -6,6 +6,7 @@ import type { OperationContract } from '../wire/OperationContract.js';
|
|
|
6
6
|
import { targetOf } from './prefab.js';
|
|
7
7
|
import type { CrudOpName } from './CrudOpName.js';
|
|
8
8
|
import type { CrudViews } from './CrudViews.js';
|
|
9
|
+
import { pageOf, type Page } from '../wire/Page.js';
|
|
9
10
|
import type { CrudOps } from './CrudOps.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -56,7 +57,7 @@ function crudOps(entity: SchemaView & { partial?: () => SchemaView }): Record<st
|
|
|
56
57
|
output: entity, cardinality: 'page',
|
|
57
58
|
binding: [{ name: 'options', source: { kind: 'query' }, optional: true }],
|
|
58
59
|
signature: {
|
|
59
|
-
name: 'list', returnType: returns(`
|
|
60
|
+
name: 'list', returnType: returns(`Page<${name}>`, 'Page'),
|
|
60
61
|
params: [{ name: 'options', type: { raw: 'ListOptions', name: 'ListOptions' }, optional: true }],
|
|
61
62
|
},
|
|
62
63
|
},
|
|
@@ -139,7 +140,7 @@ export function Crud<E extends EntityConstructor, V extends CrudViews | EntityCo
|
|
|
139
140
|
this.storage = storage as Storage<T>;
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
async list(options?: ListOptions): Promise<
|
|
143
|
+
async list(options?: ListOptions): Promise<Page<T>> { return pageOf(await this.storage.list(options)); }
|
|
143
144
|
async findById(id: string): Promise<T | undefined> { return this.storage.findById(id); }
|
|
144
145
|
async create(input: Partial<T>): Promise<T> { return this.storage.create(input); }
|
|
145
146
|
async update(id: string, input: Partial<T>): Promise<T> { return this.storage.update(id, input); }
|
package/src/prefab/CrudOps.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CrudOpName } from './CrudOpName.js';
|
|
2
2
|
import type { EntityConstructor } from '@fougere/schema';
|
|
3
3
|
import type { ListOptions } from '../storage/ListOptions.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { Page } from '../wire/Page.js';
|
|
5
5
|
import type { Storage } from '../storage/Storage.js';
|
|
6
6
|
|
|
7
7
|
/** The view an op emits, fabricated. */
|
|
@@ -16,7 +16,7 @@ type OutOf<V, K extends CrudOpName, T> =
|
|
|
16
16
|
/** The five ops, typed from the entity and its views. */
|
|
17
17
|
export interface CrudOps<T, V = {}> {
|
|
18
18
|
storage: Storage<T>;
|
|
19
|
-
list(options?: ListOptions, ...collected: never[]): Promise<
|
|
19
|
+
list(options?: ListOptions, ...collected: never[]): Promise<Page<OutOf<V, 'list', T>>>;
|
|
20
20
|
findById(id: string, ...collected: never[]): Promise<OutOf<V, 'findById', T> | undefined>;
|
|
21
21
|
create(input: Partial<T>, ...collected: never[]): Promise<OutOf<V, 'create', T>>;
|
|
22
22
|
update(id: string, input: Partial<T>, ...collected: never[]): Promise<OutOf<V, 'update', T>>;
|
|
@@ -27,7 +27,7 @@ export function Mirror<E extends EntityConstructor>(shape: E): MirrorConstructor
|
|
|
27
27
|
written += await this.storage.upsertAll(page);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
return { written, since, ms: Date.now() - started };
|
|
30
|
+
return { written, ...(since && { since: since.toISOString() }), ms: Date.now() - started };
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
package/src/prefab/Refreshed.ts
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
export interface Refreshed {
|
|
3
3
|
/** Instances written, counting a replaced one once. */
|
|
4
4
|
written: number;
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* The age the pull was asked to start from, as an ISO string — absent when it asked for
|
|
7
|
+
* everything. A STRING because a refresh is an operation: what it answers leaves through a
|
|
8
|
+
* facade, where only data crosses, and a `Date` reaches a caller here and an ISO string
|
|
9
|
+
* behind `fronds:` off the same code.
|
|
10
|
+
*/
|
|
11
|
+
since?: string;
|
|
7
12
|
/** How long the whole pass took, pull included. */
|
|
8
13
|
ms: number;
|
|
9
14
|
}
|
package/src/storage/Store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyCreate, applyUpdate,
|
|
1
|
+
import { applyCreate, applyUpdate, Role, type SchemaView } from '@fougere/schema';
|
|
2
2
|
import { comparisonOf, comparisonsIn, type Comparison } from './Comparison.js';
|
|
3
3
|
import type { Storage } from './Storage.js';
|
|
4
4
|
import type { StorageFactory } from './StorageFactory.js';
|
|
@@ -46,19 +46,16 @@ export function storageOver(open: (entity: SchemaView, name: string) => Store):
|
|
|
46
46
|
? Object.fromEntries(Object.entries(values).filter(([key]) => selected.has(key)))
|
|
47
47
|
: values);
|
|
48
48
|
|
|
49
|
-
// Same contract as SQL:
|
|
49
|
+
// Same contract as SQL: a row that is already there is UPDATED, so what the write leaves
|
|
50
|
+
// out stays where it was.
|
|
50
51
|
// Named, and not reached through `this`: a caller may have wrapped these gestures,
|
|
51
52
|
// and a derived one that goes back through the front facade is judged twice.
|
|
52
53
|
const upsert = async (input: Partial<Record<string, unknown>>): Promise<Values> => {
|
|
53
|
-
const
|
|
54
|
-
const id =
|
|
54
|
+
const created = applyCreate(fields, applyUpdate(fields, input));
|
|
55
|
+
const id = created[pk] as string | undefined;
|
|
55
56
|
if (id === undefined) throw new Error(`${name}.upsert(): no \`${pk}\` — an upsert needs the key it writes at.`);
|
|
56
57
|
const previous = await store.get(keyOf(id));
|
|
57
|
-
|
|
58
|
-
for (const [key, field] of Object.entries(fields)) {
|
|
59
|
-
if (key === pk || Lifecycle.of(field).stampedOnce) values[key] = previous[key];
|
|
60
|
-
}
|
|
61
|
-
}
|
|
58
|
+
const values = previous ? { ...previous, ...applyUpdate(fields, input) } : created;
|
|
62
59
|
await store.set(keyOf(id), values);
|
|
63
60
|
return pick(values);
|
|
64
61
|
};
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import type { OperationContext } from './OperationContext.js';
|
|
2
2
|
import type { AppNext } from './AppNext.js';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* A middleware answers what the chain answered — the same TYPE, whatever it does with the value.
|
|
6
|
+
*
|
|
7
|
+
* `T` is the whole rule and nothing enforces it at runtime: a middleware is handed a type it
|
|
8
|
+
* cannot name, so the only value of that type it can produce is the one `next()` gave it. It may
|
|
9
|
+
* observe it, log it, replace it with another of the same shape, or refuse by throwing; it may
|
|
10
|
+
* not wrap it in `{ data, meta }` nor invent one, because a caller reads the handler's signature
|
|
11
|
+
* and nothing tells that signature a middleware stood in the way.
|
|
12
|
+
*/
|
|
13
|
+
export type AppMiddleware = <T>(ctx: OperationContext, next: AppNext<T>) => Promise<T>;
|
|
5
14
|
|
|
6
15
|
// ── Runner ──────────────────────────────────────
|
|
7
16
|
|
package/src/wire/AppNext.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
/** What a middleware holds: the rest of the chain, and the answer it will hand back. */
|
|
2
|
+
export type AppNext<T = unknown> = () => Promise<T>;
|
package/src/wire/Facade.ts
CHANGED
|
@@ -10,10 +10,22 @@ type Served = keyof FougereOperations & string;
|
|
|
10
10
|
*/
|
|
11
11
|
type AddressIn<Key> = Key extends `${infer Address}.${string}` ? Address : never;
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The facade built in front of a handler — the framework's second port, after `Storage`.
|
|
15
|
+
*
|
|
16
|
+
* What the handler knows is WHICH operations exist and what each one answers. The two ends are
|
|
17
|
+
* the port's own: an invocation goes in where the handler takes positional arguments, and a
|
|
18
|
+
* promise comes back where the handler may answer a bare value. A facade is a crossing, and a
|
|
19
|
+
* crossing is awaited before any transport — the dispatch resolves a route, runs the middlewares
|
|
20
|
+
* and awaits the collectors, so `readLocation(): string` was typed as answering now and never did.
|
|
21
|
+
*
|
|
22
|
+
* `Awaited` because a promise does not stack: `Promise.resolve(p)` IS `p`, so writing
|
|
23
|
+
* `Promise<R>` over an async handler would describe a `Promise<Promise<Post>>` that no value can
|
|
24
|
+
* have — `.then` would hand its callback a promise the runtime never delivers.
|
|
25
|
+
*/
|
|
14
26
|
export type Facade<T> = {
|
|
15
27
|
[K in keyof T]: T[K] extends (...args: never[]) => infer R
|
|
16
|
-
? (invocation?: InvocationContext) => R
|
|
28
|
+
? (invocation?: InvocationContext) => Promise<Awaited<R>>
|
|
17
29
|
: never;
|
|
18
30
|
};
|
|
19
31
|
|
package/src/wire/FougereError.ts
CHANGED
|
@@ -20,7 +20,7 @@ export class FougereError<Code extends ErrorCode = ErrorCode> extends Error {
|
|
|
20
20
|
|
|
21
21
|
constructor(options: FougereErrorOptions<Code>) {
|
|
22
22
|
super(options.message, { cause: options.cause });
|
|
23
|
-
this.name =
|
|
23
|
+
this.name = 'FougereError';
|
|
24
24
|
this.code = options.code;
|
|
25
25
|
this.entity = options.entity;
|
|
26
26
|
this.operation = options.operation;
|
|
@@ -36,7 +36,7 @@ export function cardinalityOf(type: TypeRef | undefined): OperationContract['car
|
|
|
36
36
|
if (!type) return undefined;
|
|
37
37
|
const inner = type.name === 'Promise' ? type.generics?.[0] : type;
|
|
38
38
|
if (!inner) return 'none';
|
|
39
|
-
if (inner.name === 'ListResult') return 'page';
|
|
39
|
+
if (inner.name === 'Page' || inner.name === 'ListResult') return 'page';
|
|
40
40
|
if (inner.array) return 'many';
|
|
41
41
|
if (PRIMITIVE_RETURNS.has(inner.name)) return 'none';
|
|
42
42
|
return inner.nullable || inner.undefined ? 'maybe' : 'one';
|
package/src/wire/Page.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a paged operation answers — the rows, and what the storage said about the rest of them.
|
|
3
|
+
*
|
|
4
|
+
* An ENVELOPE rather than the array `Storage.list` hands back: that one carries `total` and
|
|
5
|
+
* `hasMore` as properties OF the array, and `JSON.stringify` keeps only the indices. Measured
|
|
6
|
+
* 2026-09-18 — `page.total` is 42 in this process and `undefined` behind `fronds:`, off the same
|
|
7
|
+
* code. What crosses has to be named, so it is a field.
|
|
8
|
+
*
|
|
9
|
+
* `Storage.list` keeps its array: it answers inside a frond and never crosses.
|
|
10
|
+
*/
|
|
11
|
+
export interface Page<Row> {
|
|
12
|
+
items: Row[];
|
|
13
|
+
/** Only when the caller asked to count. */
|
|
14
|
+
total?: number;
|
|
15
|
+
hasMore?: boolean;
|
|
16
|
+
endCursor?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The array `Storage.list` answers, read as the page an operation hands over.
|
|
21
|
+
*
|
|
22
|
+
* A storage handed in by a host may answer the envelope already — `storageFactory` is its door,
|
|
23
|
+
* and nothing there is obliged to subclass an array. Taken as it comes in that case.
|
|
24
|
+
*/
|
|
25
|
+
export function pageOf<Row>(rows: Page<Row> | readonly Row[]): Page<Row> {
|
|
26
|
+
if (!Array.isArray(rows)) return rows as Page<Row>;
|
|
27
|
+
|
|
28
|
+
const { total, hasMore, endCursor } = rows as unknown as Omit<Page<Row>, 'items'>;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
items: [...rows],
|
|
32
|
+
...(total !== undefined && { total }),
|
|
33
|
+
...(hasMore !== undefined && { hasMore }),
|
|
34
|
+
...(endCursor !== undefined && { endCursor }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The value read as a page, or nothing.
|
|
40
|
+
*
|
|
41
|
+
* Read from the FORM, and arbitrated by the schema: an entity legally declaring a field named
|
|
42
|
+
* `items` answers a row, not a page, and it is the only thing that could tell the two apart.
|
|
43
|
+
*/
|
|
44
|
+
export function asPage(value: unknown, fields: Record<string, unknown>): Page<unknown> | undefined {
|
|
45
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
46
|
+
if ('items' in fields) return undefined;
|
|
47
|
+
const page = value as Page<unknown>;
|
|
48
|
+
|
|
49
|
+
return Array.isArray(page.items) ? page : undefined;
|
|
50
|
+
}
|
package/src/wire/Signature.ts
CHANGED
|
@@ -10,4 +10,11 @@ export interface Signature {
|
|
|
10
10
|
inherited?: boolean;
|
|
11
11
|
/** The operation in words — the first sentence of the method's doc comment. */
|
|
12
12
|
description?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The first thing in the return type that is not data — its path, and the type sitting there.
|
|
15
|
+
*
|
|
16
|
+
* Written whenever a checker read the signature, and judged only where nothing converts the
|
|
17
|
+
* output: what a declared entity answers is encoded by its fields.
|
|
18
|
+
*/
|
|
19
|
+
notData?: string;
|
|
13
20
|
}
|