@fougere/core 0.11.0-alpha.0 → 0.12.0-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 (146) hide show
  1. package/dist/FougereConfig.d.ts +10 -6
  2. package/dist/FougereConfig.d.ts.map +1 -1
  3. package/dist/FougereConfig.js +19 -4
  4. package/dist/FougereConfig.js.map +1 -1
  5. package/dist/FrondConfig.d.ts +0 -6
  6. package/dist/FrondConfig.d.ts.map +1 -1
  7. package/dist/FrondConfig.js.map +1 -1
  8. package/dist/FrondDeclaration.d.ts +2 -9
  9. package/dist/FrondDeclaration.d.ts.map +1 -1
  10. package/dist/FrondDeclaration.js +0 -1
  11. package/dist/FrondDeclaration.js.map +1 -1
  12. package/dist/FrondsStated.d.ts +54 -0
  13. package/dist/FrondsStated.d.ts.map +1 -0
  14. package/dist/FrondsStated.js +26 -0
  15. package/dist/FrondsStated.js.map +1 -0
  16. package/dist/StatedFrond.d.ts +21 -0
  17. package/dist/StatedFrond.d.ts.map +1 -0
  18. package/dist/StatedFrond.js +34 -0
  19. package/dist/StatedFrond.js.map +1 -0
  20. package/dist/StatedModules.d.ts +19 -0
  21. package/dist/StatedModules.d.ts.map +1 -0
  22. package/dist/StatedModules.js +37 -0
  23. package/dist/StatedModules.js.map +1 -0
  24. package/dist/boot/CreateAppOptions.d.ts +19 -2
  25. package/dist/boot/CreateAppOptions.d.ts.map +1 -1
  26. package/dist/boot/Emissions.d.ts +9 -0
  27. package/dist/boot/Emissions.d.ts.map +1 -1
  28. package/dist/boot/Emissions.js +11 -5
  29. package/dist/boot/Emissions.js.map +1 -1
  30. package/dist/boot/bootstrap.d.ts.map +1 -1
  31. package/dist/boot/bootstrap.js +342 -307
  32. package/dist/boot/bootstrap.js.map +1 -1
  33. package/dist/boot/declared.d.ts.map +1 -1
  34. package/dist/boot/declared.js +3 -0
  35. package/dist/boot/declared.js.map +1 -1
  36. package/dist/boot/frame.d.ts +0 -4
  37. package/dist/boot/frame.d.ts.map +1 -1
  38. package/dist/boot/frame.js +31 -14
  39. package/dist/boot/frame.js.map +1 -1
  40. package/dist/boot/hosted.d.ts.map +1 -1
  41. package/dist/boot/hosted.js +4 -3
  42. package/dist/boot/hosted.js.map +1 -1
  43. package/dist/boot/install.d.ts +9 -0
  44. package/dist/boot/install.d.ts.map +1 -1
  45. package/dist/boot/install.js +271 -185
  46. package/dist/boot/install.js.map +1 -1
  47. package/dist/boot/nesting.d.ts +33 -0
  48. package/dist/boot/nesting.d.ts.map +1 -0
  49. package/dist/boot/nesting.js +164 -0
  50. package/dist/boot/nesting.js.map +1 -0
  51. package/dist/boot/ownership.d.ts +0 -1
  52. package/dist/boot/ownership.d.ts.map +1 -1
  53. package/dist/boot/ownership.js +66 -51
  54. package/dist/boot/ownership.js.map +1 -1
  55. package/dist/boot/ports.js +30 -25
  56. package/dist/boot/ports.js.map +1 -1
  57. package/dist/boot/remote.d.ts.map +1 -1
  58. package/dist/boot/remote.js +31 -28
  59. package/dist/boot/remote.js.map +1 -1
  60. package/dist/boot/seed.d.ts.map +1 -1
  61. package/dist/boot/seed.js +27 -20
  62. package/dist/boot/seed.js.map +1 -1
  63. package/dist/boot/together.d.ts.map +1 -1
  64. package/dist/boot/together.js +71 -56
  65. package/dist/boot/together.js.map +1 -1
  66. package/dist/descriptor/FrondDescriptor.d.ts +7 -0
  67. package/dist/descriptor/FrondDescriptor.d.ts.map +1 -1
  68. package/dist/descriptor/MiddlewareEntry.d.ts +6 -5
  69. package/dist/descriptor/MiddlewareEntry.d.ts.map +1 -1
  70. package/dist/descriptor/ProviderEntry.d.ts +7 -0
  71. package/dist/descriptor/ProviderEntry.d.ts.map +1 -1
  72. package/dist/dispatch/HandlerFacade.d.ts +6 -1
  73. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  74. package/dist/dispatch/HandlerFacade.js +23 -20
  75. package/dist/dispatch/HandlerFacade.js.map +1 -1
  76. package/dist/dispatch/PresenterExecutor.d.ts +6 -0
  77. package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
  78. package/dist/dispatch/PresenterExecutor.js +30 -22
  79. package/dist/dispatch/PresenterExecutor.js.map +1 -1
  80. package/dist/dispatch/Release.js +53 -47
  81. package/dist/dispatch/Release.js.map +1 -1
  82. package/dist/dispatch/StorageGuard.d.ts +21 -0
  83. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  84. package/dist/dispatch/StorageGuard.js +84 -66
  85. package/dist/dispatch/StorageGuard.js.map +1 -1
  86. package/dist/index.d.ts +3 -0
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +3 -0
  89. package/dist/index.js.map +1 -1
  90. package/dist/node.d.ts +2 -1
  91. package/dist/node.d.ts.map +1 -1
  92. package/dist/node.js +2 -1
  93. package/dist/node.js.map +1 -1
  94. package/dist/storage/Storage.js +1 -1
  95. package/dist/storage/Storage.js.map +1 -1
  96. package/dist/storage/Store.d.ts.map +1 -1
  97. package/dist/storage/Store.js +29 -21
  98. package/dist/storage/Store.js.map +1 -1
  99. package/dist/verify.d.ts.map +1 -1
  100. package/dist/verify.js +14 -0
  101. package/dist/verify.js.map +1 -1
  102. package/dist/wire/Invocation.d.ts +1 -1
  103. package/dist/wire/Invocation.d.ts.map +1 -1
  104. package/dist/wire/Invocation.js +1 -1
  105. package/dist/wire/Invocation.js.map +1 -1
  106. package/dist/wire/binding.d.ts.map +1 -1
  107. package/dist/wire/binding.js +29 -57
  108. package/dist/wire/binding.js.map +1 -1
  109. package/dist/wire/drift.d.ts.map +1 -1
  110. package/dist/wire/drift.js +22 -19
  111. package/dist/wire/drift.js.map +1 -1
  112. package/package.json +4 -4
  113. package/src/FougereConfig.ts +24 -10
  114. package/src/FrondConfig.ts +0 -6
  115. package/src/FrondDeclaration.ts +2 -7
  116. package/src/FrondsStated.ts +72 -0
  117. package/src/StatedFrond.ts +51 -0
  118. package/src/StatedModules.ts +58 -0
  119. package/src/boot/CreateAppOptions.ts +19 -2
  120. package/src/boot/Emissions.ts +16 -5
  121. package/src/boot/bootstrap.ts +478 -359
  122. package/src/boot/declared.ts +3 -0
  123. package/src/boot/frame.ts +42 -11
  124. package/src/boot/hosted.ts +4 -3
  125. package/src/boot/install.ts +388 -212
  126. package/src/boot/nesting.ts +196 -0
  127. package/src/boot/ownership.ts +79 -51
  128. package/src/boot/ports.ts +45 -31
  129. package/src/boot/remote.ts +47 -29
  130. package/src/boot/seed.ts +36 -27
  131. package/src/boot/together.ts +98 -63
  132. package/src/descriptor/FrondDescriptor.ts +7 -0
  133. package/src/descriptor/MiddlewareEntry.ts +6 -5
  134. package/src/descriptor/ProviderEntry.ts +7 -0
  135. package/src/dispatch/HandlerFacade.ts +27 -17
  136. package/src/dispatch/PresenterExecutor.ts +33 -22
  137. package/src/dispatch/Release.ts +68 -44
  138. package/src/dispatch/StorageGuard.ts +90 -69
  139. package/src/index.ts +3 -0
  140. package/src/node.ts +2 -1
  141. package/src/storage/Storage.ts +2 -2
  142. package/src/storage/Store.ts +39 -18
  143. package/src/verify.ts +15 -0
  144. package/src/wire/Invocation.ts +2 -1
  145. package/src/wire/binding.ts +26 -58
  146. package/src/wire/drift.ts +48 -21
@@ -1,4 +1,4 @@
1
- import { FieldSet, FieldValueValidator, InputRefusal, type Fields } from '@fougere/schema';
1
+ import { FieldSet, FieldValueValidator, InputRefusal, type Fields, type Verdict } from '@fougere/schema';
2
2
  import { COMPARISONS, comparisonOf, unknownIn } from '../storage/Comparison.js';
3
3
  import { assertListOptions } from '../storage/Storage.js';
4
4
  import { ErrorCode } from '../wire/ErrorCode.js';
@@ -34,22 +34,37 @@ export class StorageGuard {
34
34
  const writer = storage as unknown as Writer;
35
35
  if (typeof writer.create !== 'function' || typeof writer.update !== 'function') return storage;
36
36
 
37
- const validation = this;
38
37
  const guarded = Object.create(storage) as T & Writer;
39
38
 
39
+ this.guardDelete(guarded, writer);
40
+ this.guardWrites(guarded, writer);
41
+ this.guardList(guarded, writer);
42
+
43
+ return guarded;
44
+ }
45
+
46
+ /**
47
+ * What names this row goes first, and this row last: an interruption then leaves fewer
48
+ * children rather than an orphan. A key holds the rest, at the rows, in one statement.
49
+ */
50
+ private guardDelete(guarded: Writer, writer: Writer): void {
40
51
  const remove = writer.delete;
41
- if (typeof remove === 'function' && validation.releasing) {
42
- // What names this row goes first, and this row last: an interruption then leaves fewer
43
- // children rather than an orphan. A key holds the rest, at the rows, in one statement.
44
- guarded.delete = async function (id) {
45
- let gone = false;
46
- await release(validation.entity, id, validation.releasing!, [], async () => {
47
- gone = await remove.call(this, id);
48
- });
49
-
50
- return gone;
51
- };
52
- }
52
+ if (typeof remove !== 'function' || !this.releasing) return;
53
+
54
+ const validation = this;
55
+ guarded.delete = async function (id) {
56
+ let gone = false;
57
+ await release(validation.entity, id, validation.releasing!, [], async () => {
58
+ gone = await remove.call(this, id);
59
+ });
60
+
61
+ return gone;
62
+ };
63
+ }
64
+
65
+ /** Every gesture that puts a row down: judged first, and handed on the value it parsed. */
66
+ private guardWrites(guarded: Writer, writer: Writer): void {
67
+ const validation = this;
53
68
 
54
69
  guarded.create = async function (...args) {
55
70
  args[0] = validation.validated(args[0], 'create');
@@ -74,29 +89,29 @@ export class StorageGuard {
74
89
 
75
90
  const upsertAll = writer.upsertAll;
76
91
  if (typeof upsertAll === 'function') {
77
- // Every row before the first write: a page refused halfway leaves rows behind that
78
- // the caller asked for as one, and the refusal is readable from the input alone.
92
+ // Every row before the first write, and the keys of the whole page in one read per
93
+ // relation: a page refused on its fourth row has already written three.
79
94
  guarded.upsertAll = async function (...args) {
80
95
  args[0] = args[0].map((row, index) => validation.validated(row, 'upsertAll', index));
81
- // The keys of the whole page in one read per relation, for the reason above: a page
82
- // refused on its fourth row has already written three.
83
96
  await validation.targetsOf(args[0], 'upsertAll');
84
97
  return upsertAll.apply(this, args);
85
98
  };
86
99
  }
100
+ }
87
101
 
102
+ /** A criterion is read where the entity declares it, so a page is asked for what it can answer. */
103
+ private guardList(guarded: Writer, writer: Writer): void {
88
104
  const list = writer.list;
89
- if (typeof list === 'function') {
90
- guarded.list = async function (...args: unknown[]) {
91
- const options = args[0] as { where?: Record<string, unknown> } | undefined;
92
- assertListOptions(options, validation.entity, Object.keys(validation.fields));
93
- if (options?.where) args[0] = { ...options, where: validation.criteria(options.where) };
105
+ if (typeof list !== 'function') return;
94
106
 
95
- return list.apply(this, args);
96
- };
97
- }
107
+ const validation = this;
108
+ guarded.list = async function (...args: unknown[]) {
109
+ const options = args[0] as { where?: Record<string, unknown> } | undefined;
110
+ assertListOptions(options, validation.entity, Object.keys(validation.fields));
111
+ if (options?.where) args[0] = { ...options, where: validation.criteria(options.where) };
98
112
 
99
- return guarded;
113
+ return list.apply(this, args);
114
+ };
100
115
  }
101
116
 
102
117
  /**
@@ -123,34 +138,12 @@ export class StorageGuard {
123
138
  const parsed: Record<string, unknown> = {};
124
139
 
125
140
  for (const [key, asked] of Object.entries(where)) {
126
- const field = this.fields[key];
127
- if (!field) {
128
- errors.push(`${key}: ${InputRefusal.unknownField}`);
129
- continue;
130
- }
131
- // A comparison names its own vocabulary, and a typo in it would otherwise be a
132
- // criterion that filters nothing — the silent truncation this facade exists to stop.
133
- const comparison = comparisonOf(field, asked);
134
- if (comparison) {
135
- const unknown = unknownIn(comparison);
136
- if (unknown.length) {
137
- errors.push(`${key}: unknown comparison ${unknown.join(', ')} — one of ${COMPARISONS.join(', ')}`);
138
- continue;
139
- }
140
- parsed[key] = comparison;
141
+ const read = this.criterion(key, asked);
142
+ if ('message' in read) errors.push(`${key}: ${read.message}`);
143
+ else {
144
+ parsed[key] = read.value;
141
145
  this.beyondTheView(key);
142
- continue;
143
- }
144
-
145
- const values = Array.isArray(asked) ? asked : [asked];
146
- const each = values.map((value) => this.value(field, value));
147
- const refused = each.find((one) => typeof one === 'object' && one !== null && 'error' in one);
148
- if (refused) {
149
- errors.push(`${key}: ${(refused as { error: string }).error}`);
150
- continue;
151
146
  }
152
- parsed[key] = Array.isArray(asked) ? each.map(unwrap) : unwrap(each[0]);
153
- this.beyondTheView(key);
154
147
  }
155
148
 
156
149
  if (errors.length > 0) {
@@ -166,6 +159,33 @@ export class StorageGuard {
166
159
  return parsed;
167
160
  }
168
161
 
162
+ /**
163
+ * One criterion, told from a value by the FIELD and never by its own shape.
164
+ *
165
+ * A comparison names its own vocabulary, and a typo in it would otherwise be a criterion that
166
+ * filters nothing — the silent truncation this facade exists to stop.
167
+ */
168
+ private criterion(key: string, asked: unknown): Verdict {
169
+ const field = this.fields[key];
170
+ if (!field) return { message: InputRefusal.unknownField };
171
+
172
+ const comparison = comparisonOf(field, asked);
173
+ if (comparison) {
174
+ const unknown = unknownIn(comparison);
175
+
176
+ return unknown.length
177
+ ? { message: `unknown comparison ${unknown.join(', ')} — one of ${COMPARISONS.join(', ')}` }
178
+ : { value: comparison };
179
+ }
180
+
181
+ const values = Array.isArray(asked) ? asked : [asked];
182
+ const each = values.map((value) => this.value(field, value));
183
+ const refused = each.find((one) => 'message' in one);
184
+ if (refused) return refused;
185
+
186
+ return { value: Array.isArray(asked) ? each.map(unwrap) : unwrap(each[0]) };
187
+ }
188
+
169
189
  /**
170
190
  * A filter on a field this facade does not hand back.
171
191
  *
@@ -186,7 +206,19 @@ export class StorageGuard {
186
206
  }
187
207
 
188
208
  /** One value against one field — validated, then decoded the way the wire hands it. */
189
- private value(field: Fields[string], asked: unknown): { value: unknown } | { error: string } {
209
+ /**
210
+ * One key a handler wrote. A key the entity does not declare has no column to land in and no
211
+ * judge to pass: on the client facade it is a typo, and on this one a mapping that went stale.
212
+ */
213
+ private written(key: string, item: unknown): Verdict {
214
+ const field = this.fields[key];
215
+ if (!field) return { message: InputRefusal.unknownField };
216
+ if (item === undefined) return { value: item };
217
+
218
+ return FieldValueValidator.of(field).parse(item);
219
+ }
220
+
221
+ private value(field: Fields[string], asked: unknown): Verdict {
190
222
  if (asked === null || asked === undefined) return { value: asked };
191
223
  return FieldValueValidator.of(field).parse(asked);
192
224
  }
@@ -199,20 +231,9 @@ export class StorageGuard {
199
231
  const parsed: Record<string, unknown> = {};
200
232
 
201
233
  for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
202
- const field = this.fields[key];
203
- // A key the entity does not declare has no column to land in and no judge to pass:
204
- // on the client facade it is a typo, and on this one it is a mapping that went stale.
205
- if (!field) {
206
- errors.push(`${where}${key}: ${InputRefusal.unknownField}`);
207
- continue;
208
- }
209
- if (item === undefined) {
210
- parsed[key] = item;
211
- continue;
212
- }
213
- const value = FieldValueValidator.of(field).parse(item);
214
- if ('error' in value) errors.push(`${where}${key}: ${value.error}`);
215
- else parsed[key] = value.value;
234
+ const read = this.written(key, item);
235
+ if ('message' in read) errors.push(`${where}${key}: ${read.message}`);
236
+ else parsed[key] = read.value;
216
237
  }
217
238
 
218
239
  if (errors.length > 0) {
@@ -266,5 +287,5 @@ export class StorageGuard {
266
287
  }
267
288
  }
268
289
 
269
- const unwrap = (one: { value: unknown } | { error: string }): unknown =>
290
+ const unwrap = (one: Verdict): unknown =>
270
291
  'value' in one ? one.value : undefined;
package/src/index.ts CHANGED
@@ -6,6 +6,9 @@ export type { Extension } from './boot/Extension.js';
6
6
  export { defineFougere } from './define.js';
7
7
  export type { AdapterConfig } from './AdapterConfig.js';
8
8
  export type { FougereConfig } from './FougereConfig.js';
9
+ export { statesModule, type FrondsStated, type FrondStated } from './FrondsStated.js';
10
+ export { statedFronds, type StatedFrond } from './StatedFrond.js';
11
+ export { nested } from './boot/nesting.js';
9
12
  export type { AnswerFor } from './AnswerFor.js';
10
13
  export type { FougereNames } from './FougereNames.js';
11
14
  export type { FougerePorts } from './FougerePorts.js';
package/src/node.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** What Fougere does with a filesystem, minus the scan — that is `@fougere/compiler`. */
2
2
  export { setModuleLoader, getModuleLoader } from './loader.js';
3
- export { loadConfig, loadCascadedConfig } from './FougereConfig.js';
3
+ export { loadConfig, loadCascadedConfig, remotesOf } from './FougereConfig.js';
4
+ export { statedModules } from './StatedModules.js';
4
5
  export { defineFrond, loadFrondConfig } from './FrondConfig.js';
5
6
 
6
7
  // Making a key and binding a name to it happen once, at a deployment, on a machine with
@@ -172,7 +172,7 @@ const KINDS = '|';
172
172
  /** The members behind a frame key, or `undefined` when the key is not one. */
173
173
  export function membersOfTogetherKey(key: string): { entities: string[]; providers: string[] } | undefined {
174
174
  if (key.length <= FRAME.length || !key.endsWith(FRAME)) return undefined;
175
- const [entities, providers = ''] = key.slice(0, -FRAME.length).split(KINDS);
175
+ const [entities = '', providers = ''] = key.slice(0, -FRAME.length).split(KINDS);
176
176
  const split = (list: string) => list.split(SEPARATOR).filter(Boolean);
177
- return { entities: split(entities!), providers: split(providers) };
177
+ return { entities: split(entities), providers: split(providers) };
178
178
  }
@@ -3,6 +3,7 @@ import { comparisonOf, comparisonsIn, type Comparison } from './Comparison.js';
3
3
  import type { Storage } from './Storage.js';
4
4
  import type { StorageFactory } from './StorageFactory.js';
5
5
  import type { Values } from './Values.js';
6
+ import type { ListResult } from './ListResult.js';
6
7
 
7
8
  /** Instances addressed by key — what an adapter supplies, and all of it. */
8
9
  export interface Store {
@@ -68,24 +69,8 @@ export function storageOver(open: (entity: SchemaView, name: string) => Store):
68
69
  let items = await store.all();
69
70
  if (options?.where) items = items.filter((values) => matches(values, options.where));
70
71
  if (options?.orderBy) items = sorted(items, options.orderBy, options.order);
71
- // Held before the page is cut, and after the filter: `total` answers "how many
72
- // match", which is what a paginator divides. Reading `store.size` at the end
73
- // answered a different question — everything the store holds, including the ones
74
- // the filter exists to keep out of this caller's sight.
75
- const matching = items.length;
76
- const limit = options?.limit;
77
- const offset = options?.page && limit ? (options.page - 1) * limit : options?.offset ?? 0;
78
- if (offset > 0) items = items.slice(offset);
79
- const hasMore = limit ? items.length > limit : false;
80
- if (limit) items = items.slice(0, limit);
81
- // The cursor is read before the scope cuts: a view that drops the key still
82
- // pages, the way it does over SQL.
83
- const endCursor = items.length > 0 ? String((items[items.length - 1] as any)[pk] ?? '') : undefined;
84
- const result = items.map(pick) as any;
85
- result.hasMore = hasMore;
86
- result.endCursor = endCursor;
87
- if (options?.count) result.total = matching;
88
- return result;
72
+
73
+ return paged(items, options, pk, pick);
89
74
  },
90
75
  async findById(id: string) {
91
76
  const values = await store.get(keyOf(id));
@@ -206,3 +191,39 @@ const rank = (left: unknown, right: unknown): number => {
206
191
 
207
192
  return held === against ? 0 : held < against ? -1 : 1;
208
193
  };
194
+
195
+ /**
196
+ * The page cut out of what matched.
197
+ *
198
+ * `total` is held BEFORE the cut and after the filter — it answers "how many match", which is
199
+ * what a paginator divides. Reading the store's size at the end answered a different question:
200
+ * everything it holds, including the rows the filter exists to keep out of this caller's sight.
201
+ */
202
+ function paged(
203
+ matched: Values[],
204
+ options: { limit?: number; page?: number; offset?: number; count?: boolean } | undefined,
205
+ pk: string,
206
+ pick: (values: Values) => Values,
207
+ ): ListResult<Values> {
208
+ const matching = matched.length;
209
+ const limit = options?.limit;
210
+ const offset = options?.page && limit ? (options.page - 1) * limit : options?.offset ?? 0;
211
+
212
+ let items = offset > 0 ? matched.slice(offset) : matched;
213
+ const hasMore = limit ? items.length > limit : false;
214
+ if (limit) items = items.slice(0, limit);
215
+
216
+ // The cursor is read before the scope cuts: a view that drops the key still pages, the way it
217
+ // does over SQL.
218
+ const endCursor = items.length > 0
219
+ ? String((items[items.length - 1] as Record<string, unknown>)[pk] ?? '')
220
+ : undefined;
221
+
222
+ // An array that carries the page's terms on itself — what `ListResult` is.
223
+ const result = items.map(pick) as ListResult<Values>;
224
+ result.hasMore = hasMore;
225
+ result.endCursor = endCursor;
226
+ if (options?.count) result.total = matching;
227
+
228
+ return result;
229
+ }
package/src/verify.ts CHANGED
@@ -17,6 +17,16 @@ export type Misplaced = Diagnostic & {
17
17
  /** A dependency declared in a frond's scope, and what kind of thing it is. */
18
18
  type Registration = { frond: string; kind: string };
19
19
 
20
+ /** Everything above a frond in the tree — what its scope reaches by walking up. */
21
+ function ancestors(frond: FrondDescriptor, byName: Map<string, FrondDescriptor>): Set<string> {
22
+ const above = new Set<string>();
23
+ for (let at = frond.extends; at !== undefined && !above.has(at); at = byName.get(at)?.extends) {
24
+ above.add(at);
25
+ }
26
+
27
+ return above;
28
+ }
29
+
20
30
  /**
21
31
  * The container keys a frond registers in its own scope, keyed as a handler's `deps` spell them —
22
32
  * DI resolves by type name, so both sides are PascalCase.
@@ -50,6 +60,7 @@ function injectablesOf(frond: FrondDescriptor) {
50
60
  */
51
61
  export function verify(app: { fronds: readonly FrondDescriptor[] }): Misplaced[] {
52
62
  const index = new Map<string, Registration>();
63
+ const byName = new Map(app.fronds.map((frond) => [frond.name, frond]));
53
64
  for (const frond of app.fronds) {
54
65
  for (const [key, reg] of registrationsOf(frond)) index.set(key, reg);
55
66
  }
@@ -76,6 +87,10 @@ export function verify(app: { fronds: readonly FrondDescriptor[] }): Misplaced[]
76
87
  // façade key, or an unresolved name. None is a boundary crossing, and
77
88
  // an unresolved dependency is the container's complaint, not this rule's.
78
89
  if (!declared || declared.frond === frond.name) continue;
90
+ // An ANCESTOR is not across: the frond tree says this one resolves what that one
91
+ // declared, and its scope hangs off it. A sibling, a descendant or a stranger stays
92
+ // refused — inheriting code is not calling a frond.
93
+ if (ancestors(frond, byName).has(declared.frond)) continue;
79
94
  violations.push({
80
95
  code: 'cross-frond-dependency',
81
96
  severity: 'warning',
@@ -24,6 +24,8 @@ function canonicalRecord(value: unknown): Record<string, unknown> {
24
24
 
25
25
  /** Canonical invocation shared by every entry and transport. */
26
26
  export class Invocation implements InvocationContext {
27
+ static readonly empty = Invocation.from();
28
+
27
29
  readonly params: Record<string, unknown>;
28
30
  readonly query: Record<string, unknown>;
29
31
  readonly input: unknown;
@@ -53,7 +55,6 @@ export class Invocation implements InvocationContext {
53
55
  return context instanceof Invocation ? context : new Invocation(context ?? {});
54
56
  }
55
57
 
56
- static readonly empty = Invocation.from();
57
58
 
58
59
  /** Replaces the input — how the façade hands on the value it parsed. */
59
60
  withInput(input: unknown): Invocation {
@@ -48,66 +48,34 @@ export function computeBindingPlan(
48
48
  params: Param[],
49
49
  collectorTypeNames: Set<string>,
50
50
  ): BindingPlan {
51
- return params.map((param) => {
52
- const typeName = param.type.name;
53
- // `lowerFirst`, never `toLowerCase()`: the collector set is keyed the way the
54
- // scan spells it, and the two agree on one word only — `AuthorUser` looked up as
55
- // `authoruser` missed `authorUser` and fell through to branch 4, the request input.
56
- const typeKey = lowerFirst(typeName);
57
-
58
- // 0. Fact, or the same fact before it is final. Both name themselves, so nothing has
59
- // to be known in advance, and they come FIRST because branch 4 would otherwise hand
60
- // either the caller's input under the name of something that happened.
61
- const announced = param.type.name === 'Fact' || param.type.name === 'Pipe'
62
- ? param.type.generics?.[0]?.name
63
- : undefined;
64
- if (announced) {
65
- return {
66
- name: param.name,
67
- source: {
68
- kind: (param.type.name === 'Pipe' ? 'pipe' : 'fact') as 'pipe' | 'fact',
69
- factName: lowerFirst(announced),
70
- },
71
- optional: param.optional ?? false,
72
- };
73
- }
51
+ return params.map((param) => ({
52
+ name: param.name,
53
+ source: sourceOf(param, collectorTypeNames),
54
+ optional: param.optional ?? false,
55
+ }));
56
+ }
74
57
 
75
- // 1. Collector — param type matches a type some collector answers for
76
- if (collectorTypeNames.has(typeKey)) {
77
- return {
78
- name: param.name,
79
- source: { kind: 'collector' as const, typeName: typeKey },
80
- optional: param.optional ?? false,
81
- };
82
- }
58
+ /**
59
+ * Where ONE parameter gets its value, in the order the branches must be asked.
60
+ *
61
+ * A fact and a link name themselves, so they come FIRST: asked later, the last branch would hand
62
+ * the caller's input under the name of something that happened.
63
+ */
64
+ function sourceOf(param: Param, collectorTypeNames: Set<string>): ParamSource {
65
+ const typeName = param.type.name;
66
+ // `lowerFirst`, never `toLowerCase()`: the collector set is keyed the way the scan spells it,
67
+ // and the two agree on one word only — `AuthorUser` looked up as `authoruser` missed
68
+ // `authorUser` and fell through to the request input.
69
+ const typeKey = lowerFirst(typeName);
83
70
 
84
- // 2. InvocationContext — inject the full context
85
- if (typeName === 'InvocationContext') {
86
- return {
87
- name: param.name,
88
- source: { kind: 'context' as const },
89
- optional: param.optional ?? false,
90
- };
91
- }
71
+ const announced = typeName === 'Fact' || typeName === 'Pipe' ? param.type.generics?.[0]?.name : undefined;
72
+ if (announced) {
73
+ return { kind: typeName === 'Pipe' ? 'pipe' : 'fact', factName: lowerFirst(announced) };
74
+ }
92
75
 
93
- // 3. Primitives — matched by name from params > query
94
- if (PRIMITIVES.has(typeName)) {
95
- return {
96
- name: param.name,
97
- source: {
98
- kind: 'param' as const,
99
- name: param.name,
100
- coerce: coercionFor(typeName),
101
- },
102
- optional: param.optional ?? false,
103
- };
104
- }
76
+ if (collectorTypeNames.has(typeKey)) return { kind: 'collector', typeName: typeKey };
77
+ if (typeName === 'InvocationContext') return { kind: 'context' };
78
+ if (PRIMITIVES.has(typeName)) return { kind: 'param', name: param.name, coerce: coercionFor(typeName) };
105
79
 
106
- // 4. Everything else — input
107
- return {
108
- name: param.name,
109
- source: { kind: 'input' as const },
110
- optional: param.optional ?? false,
111
- };
112
- });
80
+ return { kind: 'input' };
113
81
  }
package/src/wire/drift.ts CHANGED
@@ -15,8 +15,14 @@ export interface CardDrift {
15
15
  }
16
16
 
17
17
  /** Every facade of a card, by name. */
18
- function facadesOf(card: IdentityCard, frond: string): Map<string, { ops: Set<string>; schema?: SchemaDescriptor }> {
19
- const found = new Map<string, { ops: Set<string>; schema?: SchemaDescriptor }>();
18
+ /** What a door of that card answers, and the shape of the row it hands back. */
19
+ interface Answering {
20
+ ops: Set<string>;
21
+ schema?: SchemaDescriptor;
22
+ }
23
+
24
+ function facadesOf(card: IdentityCard, frond: string): Map<string, Answering> {
25
+ const found = new Map<string, Answering>();
20
26
  for (const one of card.fronds) {
21
27
  if (one.name !== frond) continue;
22
28
  for (const facade of one.facades) {
@@ -37,37 +43,58 @@ function factsOf(card: IdentityCard, frond: string): Map<string, SchemaDescripto
37
43
 
38
44
  /** What a consumer's synced copy no longer matches in what the producer serves. */
39
45
  export function driftOf(mine: IdentityCard, theirs: IdentityCard, frond: string): CardDrift {
40
- const mineFacades = facadesOf(mine, frond);
41
- const served = facadesOf(theirs, frond);
42
46
  const drift: CardDrift = { frond, missingDoors: [], missingOps: [], shapes: [], facts: [] };
43
47
 
44
- for (const [name, facade] of mineFacades) {
45
- const there = served.get(name);
46
- if (!there) { drift.missingDoors.push(name); continue; }
48
+ driftInDoors(facadesOf(mine, frond), facadesOf(theirs, frond), drift);
49
+ driftInFacts(factsOf(mine, frond), factsOf(theirs, frond), drift);
50
+
51
+ return drift;
52
+ }
53
+
54
+ /** A door that is gone, an operation it no longer answers, or a row that changed shape. */
55
+ function driftInDoors(
56
+ mine: Map<string, Answering>,
57
+ theirs: Map<string, Answering>,
58
+ drift: CardDrift,
59
+ ): void {
60
+ for (const [name, facade] of mine) {
61
+ const there = theirs.get(name);
62
+ if (!there) {
63
+ drift.missingDoors.push(name);
64
+ continue;
65
+ }
47
66
 
48
67
  const missing = [...facade.ops].filter((op) => !there.ops.has(op));
49
68
  if (missing.length > 0) drift.missingOps.push({ facade: name, ops: missing.sort() });
50
69
 
51
- if (facade.schema && there.schema) {
52
- // `Card.diff` never guesses a rename — a field gone plus a field appeared lands in
53
- // `ambiguous`, and only a declaration settles it. Here nobody can declare one, so
54
- // the pair is reported as it is and a human reads it.
55
- const moved = Card.fromDescriptor(facade.schema).diff(Card.fromDescriptor(there.schema));
56
- if (moved.changes.length > 0) drift.shapes.push({ facade: name, changes: moved.changes });
57
- }
70
+ if (!facade.schema || !there.schema) continue;
71
+
72
+ // `Card.diff` never guesses a rename — a field gone plus a field appeared lands in
73
+ // `ambiguous`, and only a declaration settles it. Here nobody can declare one, so the
74
+ // pair is reported as it is and a human reads it.
75
+ const moved = Card.fromDescriptor(facade.schema).diff(Card.fromDescriptor(there.schema));
76
+ if (moved.changes.length > 0) drift.shapes.push({ facade: name, changes: moved.changes });
58
77
  }
78
+ }
79
+
80
+ /** A fact nobody announces any more, or one whose payload moved under its readers. */
81
+ function driftInFacts(
82
+ mine: Map<string, SchemaDescriptor | undefined>,
83
+ theirs: Map<string, SchemaDescriptor | undefined>,
84
+ drift: CardDrift,
85
+ ): void {
86
+ for (const [name, shape] of mine) {
87
+ if (!theirs.has(name)) {
88
+ drift.facts.push({ fact: name, changes: 'gone' });
89
+ continue;
90
+ }
59
91
 
60
- const heldFacts = factsOf(mine, frond);
61
- const servedFacts = factsOf(theirs, frond);
62
- for (const [name, shape] of heldFacts) {
63
- if (!servedFacts.has(name)) { drift.facts.push({ fact: name, changes: 'gone' }); continue; }
64
- const there = servedFacts.get(name);
92
+ const there = theirs.get(name);
65
93
  if (!shape || !there) continue;
94
+
66
95
  const moved = Card.fromDescriptor(shape).diff(Card.fromDescriptor(there));
67
96
  if (moved.changes.length > 0) drift.facts.push({ fact: name, changes: moved.changes });
68
97
  }
69
-
70
- return drift;
71
98
  }
72
99
 
73
100
  /** Whether anything at all separates the two cards. */