@fougere/core 0.12.4-alpha.0 → 0.12.6-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.
Files changed (130) hide show
  1. package/dist/FougereConfig.d.ts +2 -3
  2. package/dist/FougereConfig.d.ts.map +1 -1
  3. package/dist/FougereConfig.js.map +1 -1
  4. package/dist/FrondDeclaration.d.ts +5 -0
  5. package/dist/FrondDeclaration.d.ts.map +1 -1
  6. package/dist/FrondDeclaration.js +3 -0
  7. package/dist/FrondDeclaration.js.map +1 -1
  8. package/dist/FrondsStated.d.ts +4 -2
  9. package/dist/FrondsStated.d.ts.map +1 -1
  10. package/dist/FrondsStated.js.map +1 -1
  11. package/dist/StatedFrond.d.ts +3 -0
  12. package/dist/StatedFrond.d.ts.map +1 -1
  13. package/dist/StatedFrond.js +4 -0
  14. package/dist/StatedFrond.js.map +1 -1
  15. package/dist/StatedModules.d.ts +5 -2
  16. package/dist/StatedModules.d.ts.map +1 -1
  17. package/dist/StatedModules.js +11 -6
  18. package/dist/StatedModules.js.map +1 -1
  19. package/dist/boot/AppLifecycle.d.ts +3 -0
  20. package/dist/boot/AppLifecycle.d.ts.map +1 -1
  21. package/dist/boot/AppLifecycle.js +5 -0
  22. package/dist/boot/AppLifecycle.js.map +1 -1
  23. package/dist/boot/Extension.d.ts +6 -0
  24. package/dist/boot/Extension.d.ts.map +1 -1
  25. package/dist/boot/apply.d.ts.map +1 -1
  26. package/dist/boot/apply.js +9 -1
  27. package/dist/boot/apply.js.map +1 -1
  28. package/dist/boot/bootstrap.d.ts.map +1 -1
  29. package/dist/boot/bootstrap.js +14 -5
  30. package/dist/boot/bootstrap.js.map +1 -1
  31. package/dist/boot/install.d.ts +3 -0
  32. package/dist/boot/install.d.ts.map +1 -1
  33. package/dist/boot/install.js +1 -0
  34. package/dist/boot/install.js.map +1 -1
  35. package/dist/boot/nesting.d.ts.map +1 -1
  36. package/dist/boot/nesting.js +2 -1
  37. package/dist/boot/nesting.js.map +1 -1
  38. package/dist/boot/remote.d.ts +2 -1
  39. package/dist/boot/remote.d.ts.map +1 -1
  40. package/dist/boot/remote.js +7 -4
  41. package/dist/boot/remote.js.map +1 -1
  42. package/dist/boot/seed.d.ts +2 -1
  43. package/dist/boot/seed.d.ts.map +1 -1
  44. package/dist/boot/seed.js +10 -29
  45. package/dist/boot/seed.js.map +1 -1
  46. package/dist/contract.d.ts +1 -1
  47. package/dist/contract.d.ts.map +1 -1
  48. package/dist/contract.js +1 -1
  49. package/dist/contract.js.map +1 -1
  50. package/dist/descriptor/ExtensionEntry.d.ts +4 -0
  51. package/dist/descriptor/ExtensionEntry.d.ts.map +1 -1
  52. package/dist/descriptor/ExtensionEntry.js +6 -1
  53. package/dist/descriptor/ExtensionEntry.js.map +1 -1
  54. package/dist/descriptor/FrondDescriptor.d.ts +2 -0
  55. package/dist/descriptor/FrondDescriptor.d.ts.map +1 -1
  56. package/dist/descriptor/FrondDescriptor.js +5 -0
  57. package/dist/descriptor/FrondDescriptor.js.map +1 -1
  58. package/dist/dispatch/ArgumentResolver.d.ts.map +1 -1
  59. package/dist/dispatch/ArgumentResolver.js +2 -1
  60. package/dist/dispatch/ArgumentResolver.js.map +1 -1
  61. package/dist/dispatch/Facade.d.ts +3 -0
  62. package/dist/dispatch/Facade.d.ts.map +1 -1
  63. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  64. package/dist/dispatch/HandlerFacade.js +8 -4
  65. package/dist/dispatch/HandlerFacade.js.map +1 -1
  66. package/dist/index.d.ts +1 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/prefab/collector.d.ts +6 -0
  71. package/dist/prefab/collector.d.ts.map +1 -1
  72. package/dist/prefab/collector.js +26 -1
  73. package/dist/prefab/collector.js.map +1 -1
  74. package/dist/storage/Storage.d.ts +3 -2
  75. package/dist/storage/Storage.d.ts.map +1 -1
  76. package/dist/storage/Storage.js +23 -10
  77. package/dist/storage/Storage.js.map +1 -1
  78. package/dist/storage/Store.d.ts.map +1 -1
  79. package/dist/storage/Store.js +4 -2
  80. package/dist/storage/Store.js.map +1 -1
  81. package/dist/wire/BodyLimit.d.ts +14 -0
  82. package/dist/wire/BodyLimit.d.ts.map +1 -0
  83. package/dist/wire/BodyLimit.js +25 -0
  84. package/dist/wire/BodyLimit.js.map +1 -0
  85. package/dist/wire/Invocation.d.ts +2 -1
  86. package/dist/wire/Invocation.d.ts.map +1 -1
  87. package/dist/wire/Invocation.js +4 -1
  88. package/dist/wire/Invocation.js.map +1 -1
  89. package/dist/wire/InvocationContext.d.ts +2 -0
  90. package/dist/wire/InvocationContext.d.ts.map +1 -1
  91. package/dist/wire/OperationContext.d.ts +2 -0
  92. package/dist/wire/OperationContext.d.ts.map +1 -1
  93. package/dist/wire/SignedCall.d.ts +0 -2
  94. package/dist/wire/SignedCall.d.ts.map +1 -1
  95. package/dist/wire/SignedCall.js +1 -2
  96. package/dist/wire/SignedCall.js.map +1 -1
  97. package/dist/wire/StateShape.d.ts +27 -0
  98. package/dist/wire/StateShape.d.ts.map +1 -0
  99. package/dist/wire/StateShape.js +59 -0
  100. package/dist/wire/StateShape.js.map +1 -0
  101. package/package.json +4 -4
  102. package/src/FougereConfig.ts +2 -3
  103. package/src/FrondDeclaration.ts +6 -0
  104. package/src/FrondsStated.ts +4 -2
  105. package/src/StatedFrond.ts +5 -0
  106. package/src/StatedModules.ts +17 -9
  107. package/src/boot/AppLifecycle.ts +6 -0
  108. package/src/boot/Extension.ts +6 -0
  109. package/src/boot/apply.ts +11 -1
  110. package/src/boot/bootstrap.ts +16 -5
  111. package/src/boot/install.ts +4 -0
  112. package/src/boot/nesting.ts +2 -1
  113. package/src/boot/remote.ts +8 -3
  114. package/src/boot/seed.ts +11 -36
  115. package/src/contract.ts +1 -1
  116. package/src/descriptor/ExtensionEntry.ts +16 -1
  117. package/src/descriptor/FrondDescriptor.ts +7 -0
  118. package/src/dispatch/ArgumentResolver.ts +2 -1
  119. package/src/dispatch/Facade.ts +3 -0
  120. package/src/dispatch/HandlerFacade.ts +8 -3
  121. package/src/index.ts +1 -1
  122. package/src/prefab/collector.ts +29 -1
  123. package/src/storage/Storage.ts +26 -12
  124. package/src/storage/Store.ts +4 -2
  125. package/src/wire/BodyLimit.ts +28 -0
  126. package/src/wire/Invocation.ts +3 -1
  127. package/src/wire/InvocationContext.ts +2 -0
  128. package/src/wire/OperationContext.ts +2 -0
  129. package/src/wire/SignedCall.ts +0 -3
  130. package/src/wire/StateShape.ts +76 -0
@@ -88,7 +88,10 @@ export class HandlerFacade {
88
88
  );
89
89
  }
90
90
 
91
- const invocation = Invocation.from(input);
91
+ const received = Invocation.from(input);
92
+ const state = received.crossed ? { ...received.state } : this.facade.state.judge(received.state, entity, op);
93
+ const invocation = received.withState(state);
94
+ const entered = { ...state };
92
95
  const context: OperationContext = {
93
96
  entity,
94
97
  frond: this.facade.frond,
@@ -98,7 +101,7 @@ export class HandlerFacade {
98
101
  invocation,
99
102
  };
100
103
 
101
- return runMiddlewares(this.facade.middlewares(), context, () => this.answer(op, contract, context, invocation));
104
+ return runMiddlewares(this.facade.middlewares(), context, () => this.answer(op, contract, context, invocation, entered));
102
105
  }
103
106
 
104
107
  /**
@@ -110,8 +113,10 @@ export class HandlerFacade {
110
113
  contract: OperationContract,
111
114
  context: OperationContext,
112
115
  invocation: Invocation,
116
+ entered: Record<string, unknown>,
113
117
  ): Promise<unknown> {
114
- const validated = validateInput(contract.input, invocation, this.handler.address, op);
118
+ const state = this.facade.state.judge(context.state, this.handler.address, op, entered);
119
+ const validated = validateInput(contract.input, { ...invocation, state }, this.handler.address, op);
115
120
  context.invocation = validated;
116
121
 
117
122
  const args = contract.binding ? await this.arguments.resolve(contract.binding, validated) : [];
package/src/index.ts CHANGED
@@ -62,7 +62,7 @@ export { FougereError, ErrorCode, validationErrorsOf } from './contract.js';
62
62
  export type { AppMiddleware } from './wire/AppMiddleware.js';
63
63
  export type { AppNext } from './wire/AppNext.js';
64
64
  export type { OperationContext } from './wire/OperationContext.js';
65
- export { assertIdentityCard, RPC_ENTITY, MAX_BODY_BYTES } from './contract.js';
65
+ export { assertIdentityCard, RPC_ENTITY, ENVELOPE_BYTES, maxBodyBytes, maxFrameBytes, setMaxBodyBytes } from './contract.js';
66
66
  export { createLocalRunner, createAppRunner } from './boot/runner.js';
67
67
 
68
68
  // A frond an app STATES, for a boot that will not scan. No disk, no `typescript` — which
@@ -4,7 +4,9 @@
4
4
  * Documented: [collectors](https://fougere.dev/docs/business/collectors).
5
5
  */
6
6
 
7
- import { upperFirst } from '@fougere/schema';
7
+ import { FieldValueValidator, dotted, json, upperFirst, type Field, type SchemaView } from '@fougere/schema';
8
+ import { ErrorCode } from '../wire/ErrorCode.js';
9
+ import { FougereError } from '../wire/FougereError.js';
8
10
 
9
11
  export function Collector<T extends abstract new (...args: any[]) => any>(target: T) {
10
12
  class CollectorBase {
@@ -18,3 +20,29 @@ export function Collector<T extends abstract new (...args: any[]) => any>(target
18
20
  export function collectorKeyOf(type: string): string {
19
21
  return `${upperFirst(type)}Collector`;
20
22
  }
23
+
24
+ const readers = new WeakMap<object, Field>();
25
+
26
+ /**
27
+ * What a collector answered, read by the entity it names — judged and rebuilt, so a `User` that
28
+ * crossed a process as JSON reaches the handler with its dates. A collector naming no entity
29
+ * answers what it built.
30
+ */
31
+ export function collectedAs(collector: object, value: unknown): unknown {
32
+ const target = (collector.constructor as { __entity?: Partial<SchemaView> }).__entity;
33
+ if (value === null || value === undefined || typeof target?.getFields !== 'function') return value;
34
+
35
+ let reader = readers.get(target);
36
+ if (!reader) {
37
+ reader = json(target as SchemaView & (new () => unknown));
38
+ readers.set(target, reader);
39
+ }
40
+
41
+ const verdict = FieldValueValidator.of(reader).parse(value);
42
+ if ('value' in verdict) return verdict.value;
43
+
44
+ throw new FougereError({
45
+ code: ErrorCode.VALIDATION_FAILED,
46
+ message: `${collector.constructor.name}: ${verdict.path?.length ? `${dotted(verdict.path)}: ` : ''}${verdict.message}`,
47
+ });
48
+ }
@@ -2,15 +2,21 @@ import { upperFirst, lowerFirst, type SchemaView } from '@fougere/schema';
2
2
  import type { ListOptions } from './ListOptions.js';
3
3
  import type { ListResult } from './ListResult.js';
4
4
  import type { SelectOption } from './SelectOption.js';
5
+ import { ErrorCode } from '../wire/ErrorCode.js';
6
+ import { FougereError } from '../wire/FougereError.js';
5
7
 
6
- /** The keys `list()` answers to. */
7
- const LIST_OPTION_KEYS = [
8
- 'limit', 'offset', 'page', 'after', 'orderBy', 'order', 'count', 'where', 'select',
9
- ] as const;
8
+ /** The keys `list()` answers to, checked against `ListOptions` so neither can drift. */
9
+ const LIST_OPTION_KEYS = Object.keys({
10
+ limit: true, offset: true, page: true, after: true, orderBy: true, order: true, count: true, where: true, select: true,
11
+ } satisfies Record<keyof (ListOptions & SelectOption), true>);
12
+
13
+ /** What a count must be — a text that did not read as a number arrives as `NaN`. */
14
+ const COUNTS = ['limit', 'offset', 'page'] as const;
10
15
 
11
16
  /**
12
- * Refuse an option the port does not answer to, and an order by a field the entity does not
13
- * declare — an unhonoured `orderBy` returns the page in whatever order the engine chose.
17
+ * Refuse an option the port does not answer to, a count that is not one, and an order by a
18
+ * field the entity does not declare — an unhonoured `orderBy` returns the page in whatever order
19
+ * the engine chose. Refused as BAD_REQUEST: a plain Error left as INTERNAL_ERROR, its message hidden.
14
20
  */
15
21
  export function assertListOptions(
16
22
  options: object | undefined,
@@ -18,21 +24,29 @@ export function assertListOptions(
18
24
  declared: readonly string[],
19
25
  ): void {
20
26
  if (!options) return;
21
- const legal = new Set<string>(LIST_OPTION_KEYS);
22
- const strangers = Object.keys(options).filter((key) => !legal.has(key));
27
+ const strangers = Object.keys(options).filter((key) => !LIST_OPTION_KEYS.includes(key));
23
28
  if (strangers.length > 0) {
24
- throw new Error(
29
+ refuse(
25
30
  `${entity}.list(): unknown option ${strangers.map((s) => `\`${s}\``).join(', ')}. ` +
26
31
  `Known options are ${LIST_OPTION_KEYS.join(', ')} — to filter, pass \`where: { ${strangers[0]}: … }\`.`,
27
32
  );
28
33
  }
29
34
 
35
+ for (const count of COUNTS) {
36
+ const value = (options as ListOptions)[count];
37
+ if (value !== undefined && !(Number.isInteger(value) && value >= 0)) {
38
+ refuse(`${entity}.list(): \`${count}\` is ${typeof value === 'number' ? value : JSON.stringify(value)}, not a whole number.`);
39
+ }
40
+ }
41
+
30
42
  const { orderBy } = options as ListOptions;
31
43
  if (orderBy === undefined || declared.includes(orderBy)) return;
32
44
 
33
- throw new Error(
34
- `${entity}.list(): unknown orderBy \`${orderBy}\`. ${entity} declares ${declared.join(', ')}.`,
35
- );
45
+ refuse(`${entity}.list(): unknown orderBy \`${orderBy}\`. ${entity} declares ${declared.join(', ')}.`);
46
+ }
47
+
48
+ function refuse(message: string): never {
49
+ throw new FougereError({ code: ErrorCode.BAD_REQUEST, message });
36
50
  }
37
51
 
38
52
  /** Per-entity storage — scoped CRUD operations on a single entity type. */
@@ -1,6 +1,8 @@
1
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
+ import { ErrorCode } from '../wire/ErrorCode.js';
5
+ import { FougereError } from '../wire/FougereError.js';
4
6
  import type { StorageFactory } from './StorageFactory.js';
5
7
  import type { Values } from './Values.js';
6
8
  import type { ListResult } from './ListResult.js';
@@ -128,7 +130,7 @@ export function storageOver(open: (entity: SchemaView, name: string) => Store):
128
130
  // constraint violation, and a store that loses data silently is worse than one
129
131
  // that fails.
130
132
  if (await store.has(keyOf(id))) {
131
- throw new Error(`${name}.create: '${pk}' ${JSON.stringify(id)} already exists.`);
133
+ throw new FougereError({ code: ErrorCode.CONFLICT, message: `${name}.create: '${pk}' ${JSON.stringify(id)} already exists.` });
132
134
  }
133
135
  await store.set(keyOf(id), values);
134
136
 
@@ -136,7 +138,7 @@ export function storageOver(open: (entity: SchemaView, name: string) => Store):
136
138
  },
137
139
  async update(id: string, input: Partial<Record<string, unknown>>) {
138
140
  const existing = await store.get(keyOf(id));
139
- if (!existing) throw new Error(`Not found: ${id}`);
141
+ if (!existing) throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `No row in ${name} for ${JSON.stringify(id)}.` });
140
142
  const updated = { ...existing, ...applyUpdate(fields, input), [pk]: existing[pk] };
141
143
  await store.set(keyOf(id), updated);
142
144
 
@@ -0,0 +1,28 @@
1
+ /** How much a caller may send — held ONCE for the process and CONSULTED at every door, like the log level. */
2
+ let limit = 2 * 1024 * 1024;
3
+
4
+ /** The room a hop keeps for what it adds around a call — trace, identity, state — which never counts against the caller. */
5
+ export const ENVELOPE_BYTES = 64 * 1024;
6
+
7
+ export function maxBodyBytes(): number {
8
+ return limit;
9
+ }
10
+
11
+ /** Set by `applyConfig` from `maxBodyBytes:`. */
12
+ export function setMaxBodyBytes(bytes: number): void {
13
+ if (!Number.isInteger(bytes) || bytes <= 0) {
14
+ throw new Error(`maxBodyBytes: a positive whole number of bytes — got ${String(bytes)}.`);
15
+ }
16
+ limit = bytes;
17
+ }
18
+
19
+ /**
20
+ * What a process forwarding a call may frame and a receiver may read: the caller's limit, and the
21
+ * envelope's room. Measured against the caller's limit alone, a body just under it passed in
22
+ * process and was refused a hop away.
23
+ *
24
+ * Documented: [gradient](https://fougere.dev/docs/infra/gradient).
25
+ */
26
+ export function maxFrameBytes(): number {
27
+ return limit + ENVELOPE_BYTES;
28
+ }
@@ -36,8 +36,9 @@ export class Invocation implements InvocationContext {
36
36
  readonly identity?: string;
37
37
  readonly caller?: string;
38
38
  readonly runAt?: number;
39
+ readonly crossed?: true;
39
40
 
40
- /** Gives every entry and every transport the same eight-member value, frozen. */
41
+ /** Gives every entry and every transport the same nine-member value, frozen. */
41
42
  private constructor(context: PartialInvocation) {
42
43
  this.params = canonicalRecord(context.params);
43
44
  this.query = canonicalRecord(context.query);
@@ -49,6 +50,7 @@ export class Invocation implements InvocationContext {
49
50
  if (context.identity !== undefined) this.identity = context.identity;
50
51
  if (context.caller !== undefined) this.caller = context.caller;
51
52
  if (context.runAt !== undefined) this.runAt = context.runAt;
53
+ if (context.crossed) this.crossed = true;
52
54
  Object.freeze(this);
53
55
  }
54
56
 
@@ -8,4 +8,6 @@ export interface InvocationContext {
8
8
  caller?: string;
9
9
  /** Epoch milliseconds before which this call is not to run. */
10
10
  runAt?: number;
11
+ /** Written by the receiver of a call from another process, whose state was judged where it entered. */
12
+ crossed?: true;
11
13
  }
@@ -15,4 +15,6 @@ export interface OperationContext {
15
15
  state: Record<string, unknown>;
16
16
  /** Transport-agnostic invocation context (params, query, input, state). */
17
17
  invocation?: import('./InvocationContext.js').InvocationContext;
18
+ /** Set by the facade that hands the call to a transport — the dual of `invocation.crossed`. */
19
+ crosses?: true;
18
20
  }
@@ -1,6 +1,3 @@
1
- /** What a receiver accepts before it stops reading an input. */
2
- export const MAX_BODY_BYTES = 1024 * 1024;
3
-
4
1
  /** Everything a caller's envelope covers — the call as the sender meant it. */
5
2
  export interface SignedCall {
6
3
  entity: string;
@@ -0,0 +1,76 @@
1
+ import { InputValidator, dotted, optional, type Field, type Fields } from '@fougere/schema';
2
+ import { ErrorCode } from './ErrorCode.js';
3
+ import { FougereError } from './FougereError.js';
4
+
5
+ /** An extension and the members it puts on a call's `state`. */
6
+ export interface StateDeclaration {
7
+ name: string;
8
+ state?: Readonly<Record<string, Field>>;
9
+ }
10
+
11
+ /**
12
+ * What a call's `state` may hold in this process: the members its extensions declare, each one
13
+ * absent until a host fills it. Judged at the facade, so a member arriving over the wire is
14
+ * rebuilt by its field exactly as it would have been handed over in-process.
15
+ *
16
+ * Documented: [collectors](https://fougere.dev/docs/business/collectors).
17
+ */
18
+ export class StateShape {
19
+ static readonly empty = new StateShape({});
20
+
21
+ private readonly validator: InputValidator;
22
+
23
+ private constructor(private readonly fields: Fields) {
24
+ this.validator = InputValidator.of(fields);
25
+ }
26
+
27
+ /** Two extensions declaring one member refuse: the field that judges it would depend on wiring order. */
28
+ static of(declarations: readonly StateDeclaration[]): StateShape {
29
+ const fields: Fields = {};
30
+ const owners = new Map<string, string>();
31
+ for (const { name, state } of declarations) {
32
+ for (const [member, field] of Object.entries(state ?? {})) {
33
+ const owner = owners.get(member);
34
+ if (owner) {
35
+ throw new Error(
36
+ `[state] '${member}' is declared by two extensions, '${owner}' and '${name}'.\n`
37
+ + ' A member has one owner — keep one of them out of `extensions:`.',
38
+ );
39
+ }
40
+ owners.set(member, name);
41
+ fields[member] = optional(field);
42
+ }
43
+ }
44
+
45
+ return new StateShape(fields);
46
+ }
47
+
48
+ /**
49
+ * The members written since `entered` rebuilt by their fields, or a refusal naming the member
50
+ * and what this process declares. What `entered` already held was judged where it entered.
51
+ */
52
+ judge(
53
+ state: Record<string, unknown>,
54
+ entity: string,
55
+ operation: string,
56
+ entered: Record<string, unknown> = {},
57
+ ): Record<string, unknown> {
58
+ const written = Object.fromEntries(Object.entries(state).filter(([member, value]) => entered[member] !== value));
59
+ if (Object.keys(written).length === 0) return state;
60
+
61
+ const result = this.validator.validate(written);
62
+ if (result.success) return { ...state, ...result.data };
63
+
64
+ const declared = Object.keys(this.fields);
65
+ const errors = result.errors.map((error) => ({ ...error, path: ['state', ...error.path] }));
66
+
67
+ throw new FougereError({
68
+ code: ErrorCode.VALIDATION_FAILED,
69
+ message: errors.map((error) => `${dotted(error.path)}: ${error.message}`).join(', ')
70
+ + (declared.length ? ` — this process declares ${declared.join(', ')}` : ' — this process declares no state member'),
71
+ details: errors,
72
+ entity,
73
+ operation,
74
+ });
75
+ }
76
+ }