@fougere/core 0.8.4-alpha.0 → 0.9.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 (112) hide show
  1. package/dist/boot/AppLifecycle.d.ts +8 -0
  2. package/dist/boot/AppLifecycle.d.ts.map +1 -1
  3. package/dist/boot/AppLifecycle.js.map +1 -1
  4. package/dist/boot/Emissions.d.ts +26 -0
  5. package/dist/boot/Emissions.d.ts.map +1 -1
  6. package/dist/boot/Emissions.js +143 -8
  7. package/dist/boot/Emissions.js.map +1 -1
  8. package/dist/boot/bootstrap.d.ts.map +1 -1
  9. package/dist/boot/bootstrap.js +77 -18
  10. package/dist/boot/bootstrap.js.map +1 -1
  11. package/dist/boot/hosted.d.ts.map +1 -1
  12. package/dist/boot/hosted.js +1 -0
  13. package/dist/boot/hosted.js.map +1 -1
  14. package/dist/boot/install.d.ts +2 -0
  15. package/dist/boot/install.d.ts.map +1 -1
  16. package/dist/boot/install.js +33 -4
  17. package/dist/boot/install.js.map +1 -1
  18. package/dist/boot/ports.d.ts +12 -3
  19. package/dist/boot/ports.d.ts.map +1 -1
  20. package/dist/boot/ports.js +51 -21
  21. package/dist/boot/ports.js.map +1 -1
  22. package/dist/boot/remote.d.ts.map +1 -1
  23. package/dist/boot/remote.js +3 -3
  24. package/dist/boot/remote.js.map +1 -1
  25. package/dist/boot/types.d.ts +16 -1
  26. package/dist/boot/types.d.ts.map +1 -1
  27. package/dist/builtin/LogLine.d.ts +38 -0
  28. package/dist/builtin/LogLine.d.ts.map +1 -0
  29. package/dist/builtin/LogLine.js +37 -0
  30. package/dist/builtin/LogLine.js.map +1 -0
  31. package/dist/builtin/logger.d.ts +57 -0
  32. package/dist/builtin/logger.d.ts.map +1 -1
  33. package/dist/builtin/logger.js +72 -17
  34. package/dist/builtin/logger.js.map +1 -1
  35. package/dist/config-loader.d.ts +2 -1
  36. package/dist/config-loader.d.ts.map +1 -1
  37. package/dist/config-loader.js.map +1 -1
  38. package/dist/contract.d.ts +3 -7
  39. package/dist/contract.d.ts.map +1 -1
  40. package/dist/contract.js +3 -4
  41. package/dist/contract.js.map +1 -1
  42. package/dist/conventions.d.ts +6 -0
  43. package/dist/conventions.d.ts.map +1 -1
  44. package/dist/conventions.js +3 -2
  45. package/dist/conventions.js.map +1 -1
  46. package/dist/declare.d.ts +11 -1
  47. package/dist/declare.d.ts.map +1 -1
  48. package/dist/declare.js +9 -0
  49. package/dist/declare.js.map +1 -1
  50. package/dist/descriptor/frond.d.ts +34 -0
  51. package/dist/descriptor/frond.d.ts.map +1 -1
  52. package/dist/descriptor/index.d.ts +1 -1
  53. package/dist/descriptor/index.d.ts.map +1 -1
  54. package/dist/descriptor/index.js +1 -1
  55. package/dist/descriptor/index.js.map +1 -1
  56. package/dist/dispatch/ArgumentResolver.d.ts.map +1 -1
  57. package/dist/dispatch/ArgumentResolver.js +6 -2
  58. package/dist/dispatch/ArgumentResolver.js.map +1 -1
  59. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  60. package/dist/dispatch/HandlerFacade.js +2 -2
  61. package/dist/dispatch/HandlerFacade.js.map +1 -1
  62. package/dist/effective-operation.d.ts.map +1 -1
  63. package/dist/effective-operation.js +11 -7
  64. package/dist/effective-operation.js.map +1 -1
  65. package/dist/frond-config.d.ts +24 -0
  66. package/dist/frond-config.d.ts.map +1 -1
  67. package/dist/frond-config.js.map +1 -1
  68. package/dist/index.d.ts +30 -33
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +14 -13
  71. package/dist/index.js.map +1 -1
  72. package/dist/wire/Invocation.d.ts +1 -3
  73. package/dist/wire/Invocation.d.ts.map +1 -1
  74. package/dist/wire/Invocation.js +1 -5
  75. package/dist/wire/Invocation.js.map +1 -1
  76. package/dist/wire/binding.d.ts +9 -0
  77. package/dist/wire/binding.d.ts.map +1 -1
  78. package/dist/wire/binding.js +11 -6
  79. package/dist/wire/binding.js.map +1 -1
  80. package/dist/wire/emit.d.ts +35 -3
  81. package/dist/wire/emit.d.ts.map +1 -1
  82. package/dist/wire/emit.js +26 -3
  83. package/dist/wire/emit.js.map +1 -1
  84. package/dist/wire/loggerMiddleware.d.ts.map +1 -1
  85. package/dist/wire/loggerMiddleware.js +6 -0
  86. package/dist/wire/loggerMiddleware.js.map +1 -1
  87. package/package.json +4 -4
  88. package/src/boot/AppLifecycle.ts +8 -0
  89. package/src/boot/Emissions.ts +176 -8
  90. package/src/boot/bootstrap.ts +89 -17
  91. package/src/boot/hosted.ts +3 -1
  92. package/src/boot/install.ts +35 -4
  93. package/src/boot/ports.ts +71 -27
  94. package/src/boot/remote.ts +3 -3
  95. package/src/boot/types.ts +16 -1
  96. package/src/builtin/LogLine.ts +38 -0
  97. package/src/builtin/logger.ts +109 -17
  98. package/src/config-loader.ts +2 -1
  99. package/src/contract.ts +5 -9
  100. package/src/conventions.ts +9 -2
  101. package/src/declare.ts +19 -1
  102. package/src/descriptor/frond.ts +31 -0
  103. package/src/descriptor/index.ts +1 -1
  104. package/src/dispatch/ArgumentResolver.ts +6 -2
  105. package/src/dispatch/HandlerFacade.ts +2 -2
  106. package/src/effective-operation.ts +13 -7
  107. package/src/frond-config.ts +20 -0
  108. package/src/index.ts +36 -50
  109. package/src/wire/Invocation.ts +2 -7
  110. package/src/wire/binding.ts +17 -6
  111. package/src/wire/emit.ts +58 -6
  112. package/src/wire/loggerMiddleware.ts +6 -0
@@ -7,7 +7,13 @@ import { installFrond, type Assembly } from './install.js';
7
7
  import type { AuthRuntime } from './auth.js';
8
8
  import type { CreateAppOptions, App } from './types.js';
9
9
  import type { AppMiddleware } from '../wire/middleware.js';
10
- import { Logger } from '../builtin/logger.js';
10
+ import { Carry, Logger } from '../builtin/logger.js';
11
+ import type { LogRecord } from '../builtin/logger.js';
12
+ import LogLine, { CARRIES_LINE } from '../builtin/LogLine.js';
13
+ import { emitKeyOf, type Emit } from '../wire/emit.js';
14
+
15
+ /** The fact the boot announces, spelled once. */
16
+ const LOG_LINE = lowerFirst(LogLine.name);
11
17
  import { Config } from '../builtin/config.js';
12
18
  import { createRemoteRouter, createRemoteFacade } from './remote.js';
13
19
  import { registerFrames } from './together.js';
@@ -26,7 +32,8 @@ import { InFlight } from '../dispatch/InFlight.js';
26
32
  // The keys, each read from where its concept is declared — never respelled here.
27
33
  import { facadeKeyOf, contractsKeyOf, type RpcAnswer } from '../wire/call.js';
28
34
  import { identityCardOf } from './card.js';
29
- import { AppLifecycle } from './AppLifecycle.js';
35
+ import { AppLifecycle, migrating } from './AppLifecycle.js';
36
+ import { seeding } from './seed.js';
30
37
  import { inheritsCrud, subjectOf } from '../prefab/crud.js';
31
38
  import { repositoryKeyOf } from '../prefab/repository.js';
32
39
  import { storageKeyOf } from '../storage/port.js';
@@ -83,9 +90,20 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
83
90
 
84
91
  // Held out here, and not where the ascent reads it, because releasing needs it and
85
92
  // releasing has to work from the first line the boot takes something.
86
- const appLifecycle = new AppLifecycle().add(...(options.extensions ?? []));
93
+ // The conventional ascent, ordered here: tables, then rows, then whatever the host took
94
+ // on. Four hosts assembled these two members themselves — the order is not theirs to
95
+ // choose, and a host that forgot lost its migration in silence.
96
+ const appLifecycle = new AppLifecycle().add(
97
+ migrating(options.migrate),
98
+ seeding(),
99
+ ...(options.extensions ?? []),
100
+ );
87
101
  /** The app once it exists — a refusal before that releases the two levels that do. */
88
102
  let built: App | undefined;
103
+ /** Where THIS boot's lines wait — never a process-wide slot, see `Carry`. */
104
+ const carry = new Carry();
105
+ /** Given back by `carry.to`, so a released app stops writing into a dead container. */
106
+ let stopAnnouncing: (() => void) | undefined;
89
107
 
90
108
  /**
91
109
  * Everything this app holds, let go in reverse of how it was taken: what an extension took on
@@ -97,6 +115,9 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
97
115
  // there and broken here, a refusing extension took the container and the connection
98
116
  // down with it, which is the leak this gesture exists to prevent.
99
117
  const refused: unknown[] = [];
118
+ // Whatever is still held will never reach a destination — the console had it.
119
+ stopAnnouncing?.();
120
+ carry.forget();
100
121
  const levels = [
101
122
  ...(built ? [() => appLifecycle.down(built!)] : []),
102
123
  () => container.dispose(),
@@ -119,14 +140,14 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
119
140
 
120
141
  try {
121
142
  // Boot chatter is debug by default; a host (e.g. the CLI) can quiet it.
122
- const log = new Logger('boot:app');
143
+ const log = new Logger('boot:app', { carry });
123
144
 
124
145
  // Builtins — registered under class name (PascalCase) for type-based DI.
125
146
  // No level here and none anywhere: a logger consults `setLogLevel`'s value at each
126
147
  // emission, so this instance survives a level change and so does every handler that
127
148
  // was handed it. A frond declaring `class X extends Logger` takes this key over,
128
149
  // like any other port.
129
- container.registerValue('Logger', new Logger('app'));
150
+ container.registerValue('Logger', new Logger('app', { carry }));
130
151
  container.register('Config', Config, { lifetime: 'singleton' });
131
152
  log.debug('builtins registered (Logger, Config)');
132
153
 
@@ -134,12 +155,20 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
134
155
  // it may read a disk; consuming it never does, which is the whole reason this file names
135
156
  // no builtin and a Worker can run what it builds.
136
157
  const scanStart = performance.now();
137
- const { fronds, diagnostics } = await hostedBy(options);
158
+ // An extension's fronds sit beside the app's own: it is installed like any other, and
159
+ // its handlers resolve at call time — by which point the extension's `up` has put what
160
+ // they ask for in the container.
161
+ const brought = (options.extensions ?? [])
162
+ .flatMap((extension) => extension?.fronds ?? [])
163
+ .map((frond) => ({ ...frond, brought: true as const }));
164
+ const { fronds, diagnostics } = await hostedBy(
165
+ brought.length > 0 ? { ...options, fronds: [...(options.fronds ?? []), ...brought] } : options,
166
+ );
138
167
  // An app that states nothing AND scans nothing is a mistake — unless something else it
139
168
  // declares brings its own entities, which an auth provider does. Refused here and not in
140
169
  // `hostedBy`, which is handed the frond sources and cannot see the rest of the app. The
141
170
  // condition is the KEYS, not the count: a scan that found nothing is an ordinary answer.
142
- if (!options.fronds && !options.scan && !options.auth) {
171
+ if (!options.fronds && !options.scan && !options.auth && brought.length === 0) {
143
172
  throw new Error(
144
173
  'createApp needs `fronds:` (what this app states) or `scan:` (what a scanner found). '
145
174
  + 'Neither was given, and nothing else declares entities of its own.',
@@ -233,15 +262,41 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
233
262
  return [...globalMiddlewares, ...scoped];
234
263
  }
235
264
 
265
+ /**
266
+ * The one place a middleware is taken on. `App.use` is its late form, and a frond's
267
+ * `middlewares/` its early one — the app does not exist yet while fronds install.
268
+ */
269
+ function use(middleware: AppMiddleware, entity?: string): void {
270
+ if (entity === undefined) {
271
+ globalMiddlewares.push(middleware);
272
+ return;
273
+ }
274
+ const scoped = scopedMiddlewares.get(entity) ?? [];
275
+ scoped.push(middleware);
276
+ scopedMiddlewares.set(entity, scoped);
277
+ }
278
+
236
279
  assertOneOwnerPerKey(fronds, options.remotes);
237
280
 
238
281
  // Every entity of every frond, by name — so a fact can be validated where it LANDS, and
239
282
  // so a `reads:` clause can name a neighbour's.
240
283
  const entityByName = fronds.schemas();
284
+ // The line is core's, so its SHAPE is too: a destination that declares only a handler
285
+ // would otherwise be handed a line with no `at` — the announcement stamps `created()`
286
+ // off the shape, and the strict judge refuses what it did not stamp. Measured on
287
+ // `demos/observability`, where the ring held 11 calls and 0 lines.
288
+ if (!entityByName.has(LOG_LINE)) entityByName.set(LOG_LINE, LogLine);
241
289
  // Which frond holds an entity — what turns "a member is remote" into a refusal that
242
290
  // names the frond rather than the entity, since `remotes:` is declared per frond.
243
291
  const frondOf = new Map(fronds.flatMap((f) => f.entities.map((e) => [e.name, f.name] as const)));
244
- const emissions = new Emissions(fronds, entityByName, container, log, options.onEmit);
292
+ // Its own writer, which does NOT announce: this is what carries a fact, and a line
293
+ // about carrying one would come back here. See `LoggerOptions.carries`.
294
+ const emissions = new Emissions(
295
+ fronds, entityByName, container,
296
+ // No carry: this is what CARRIES a fact, and a line about carrying one comes back.
297
+ new Logger('boot:app'),
298
+ options.onEmit,
299
+ );
245
300
  /** Canonical operation tables, indexed by the same audience key as their facades. */
246
301
  const effectiveByKey = new Map<string, EffectiveOperationsMap>();
247
302
 
@@ -258,7 +313,7 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
258
313
  // table, one emission list — so what a frond serves is there for the next one to find.
259
314
  const assembly: Assembly = {
260
315
  container, routeRegistry, emissions, dispatcher, localDispatcher, effectiveByKey,
261
- boundPorts, operationModel, entityByName, frondOf, contractsOf, getMiddlewares,
316
+ boundPorts, operationModel, entityByName, frondOf, contractsOf, getMiddlewares, use,
262
317
  log, options,
263
318
  };
264
319
  for (const frond of fronds) await installFrond(frond, assembly);
@@ -277,6 +332,28 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
277
332
  // Once every door exists: what is announced here and what is listened to are both known.
278
333
  emissions.register();
279
334
 
335
+ // The boot's own lines, and every line after them. Held until here because a boot
336
+ // writes most of what a process logs and writes it before any door exists — so the
337
+ // lines that say what this app is made of are the ones a destination would miss.
338
+ // `LogLine` is core's for this reason: naming it costs no optional package.
339
+ // Which doors carry a line, read from who SUBSCRIBED — so a third party's destination
340
+ // is left alone by the two middlewares that observe every operation.
341
+ for (const door of emissions.doorsFor(LOG_LINE)) {
342
+ CARRIES_LINE.add(door.replace(/Handler$/, '').replace(/^./, (c) => c.toLowerCase()));
343
+ CARRIES_LINE.add(door);
344
+ }
345
+
346
+ if (emissions.listensTo().includes(LOG_LINE)) {
347
+ const emit = container.resolve<Emit<LogLine>>(emitKeyOf(LogLine.name));
348
+ // `at` is the record's own epoch, and the entity says `created()` — so the line
349
+ // keeps WHEN IT WAS WRITTEN rather than when it was handed over, which for a held
350
+ // boot line is a different moment.
351
+ stopAnnouncing = carry.to(({ at, ...line }: LogRecord) => void emit({ ...line, at: new Date(at) }));
352
+ } else {
353
+ // No destination in this app: the console had them, and holding more would grow.
354
+ carry.forget();
355
+ }
356
+
280
357
  /** The last resort, held by the container so every resolution path shares it. */
281
358
  container.setFallback?.((name) => {
282
359
  if (!remoteRouter) return undefined;
@@ -485,14 +562,9 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
485
562
  return dispatchLifecycle.add(observer);
486
563
  },
487
564
  use(...args: [AppMiddleware] | [string, AppMiddleware]): void {
488
- if (typeof args[0] === 'string') {
489
- const [entity, mw] = args as [string, AppMiddleware];
490
- const list = scopedMiddlewares.get(entity) ?? [];
491
- list.push(mw);
492
- scopedMiddlewares.set(entity, list);
493
- } else {
494
- globalMiddlewares.push(args[0] as AppMiddleware);
495
- }
565
+ return typeof args[0] === 'string'
566
+ ? use(args[1] as AppMiddleware, args[0])
567
+ : use(args[0] as AppMiddleware);
496
568
  },
497
569
  auth: authRuntime,
498
570
  };
@@ -12,7 +12,8 @@ export interface HostedSources {
12
12
  }
13
13
 
14
14
  /** The members a frond takes from a scan when it named none of its own. */
15
- type Fillable = 'providers' | 'entities' | 'handlers' | 'presenters' | 'collectors' | 'seeds';
15
+ type Fillable = 'providers' | 'entities' | 'handlers' | 'presenters' | 'collectors' | 'seeds'
16
+ | 'middlewares';
16
17
 
17
18
  /** A frond the host passed, completed by what a scan found under the same name. */
18
19
  function completed(given: FrondDescriptor, found: FrondDescriptor | undefined): FrondDescriptor {
@@ -33,6 +34,7 @@ function completed(given: FrondDescriptor, found: FrondDescriptor | undefined):
33
34
  presenters: fill('presenters'),
34
35
  collectors: fill('collectors'),
35
36
  seeds: fill('seeds'),
37
+ middlewares: fill('middlewares'),
36
38
  };
37
39
  }
38
40
 
@@ -52,6 +52,8 @@ export interface Assembly {
52
52
  contractsOf: (operations: EffectiveOperationsMap) => OperationsMap;
53
53
  /** Read at call time and never at boot, so a late registration still applies. */
54
54
  getMiddlewares: (entity: string) => AppMiddleware[];
55
+ /** Take a middleware on — every entity when no entity is named. */
56
+ use: (middleware: AppMiddleware, entity?: string) => void;
55
57
  log: Logger;
56
58
  options: CreateAppOptions;
57
59
  }
@@ -59,7 +61,7 @@ export interface Assembly {
59
61
  export async function installFrond(frond: FrondDescriptor, assembly: Assembly): Promise<void> {
60
62
  const {
61
63
  container, routeRegistry, emissions, dispatcher, localDispatcher, effectiveByKey,
62
- boundPorts, operationModel, entityByName, frondOf, contractsOf, getMiddlewares,
64
+ boundPorts, operationModel, entityByName, frondOf, contractsOf, getMiddlewares, use,
63
65
  log, options,
64
66
  } = assembly;
65
67
 
@@ -126,10 +128,22 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
126
128
  // reaches the realization instead of the base class it is declared against.
127
129
  // Registered AFTER the loop above so a port key always wins over the base's
128
130
  // own registration — same precedence as a declared repository over its default.
129
- for (const [port, impl] of portBindings(frond.providers, (n) => scope.has(n), options.ports)) {
130
- scope.register(port, impl.ctor, { deps: impl.deps });
131
+ for (const [port, chain] of portBindings(frond.providers, (n) => scope.has(n), options.ports)) {
132
+ // Registered from the INSIDE OUT, each wrapper asking for the one it stands in front
133
+ // of: the container resolves a dep by NAME, so wrapping is a substituted key and
134
+ // needs nothing of the container itself. The outermost answers under the port.
135
+ let inner = nameOf(chain.at(-1)!);
136
+ for (const wrapper of [...chain.slice(0, -1)].reverse()) {
137
+ const deps = wrapper.deps.map((dep) => (dep === port ? inner : dep));
138
+ inner = nameOf(wrapper);
139
+ scope.register(inner, wrapper.ctor, { deps });
140
+ }
141
+ const outermost = chain[0]!;
142
+ scope.register(port, outermost.ctor, {
143
+ deps: outermost.deps.map((dep) => (dep === port ? nameOf(chain[1]!) : dep)),
144
+ });
131
145
  boundPorts.add(port);
132
- frondLog.debug(`port ${port} → ${impl.ctor.name}`);
146
+ frondLog.debug(`port ${port} → ${chain.map((one) => one.ctor.name).join(' → ')}`);
133
147
  }
134
148
  if (frond.providers.length > 0) {
135
149
  frondLog.debug(`${frond.providers.length} provider(s): ${frond.providers.map(nameOf).join(', ')}`);
@@ -226,6 +240,23 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
226
240
  frondLog.debug(`${frond.collectors.length} collector(s): ${frond.collectors.map((c) => c.typeName).join(', ')}`);
227
241
  }
228
242
 
243
+ // Register middlewares in scope, then take them on. Resolved per call and never here:
244
+ // a middleware asking for something request-scoped would otherwise be handed the one
245
+ // instance the boot built — the same reason `getMiddlewares` is read at call time.
246
+ for (const middleware of frond.middlewares) {
247
+ scope.register(middleware.name, middleware.ctor, { deps: middleware.deps });
248
+ const around: AppMiddleware = (context, next) =>
249
+ scope.resolve<{ around: AppMiddleware }>(middleware.name).around(context, next);
250
+
251
+ if (middleware.scope === 'app') use(around);
252
+ // Its own frond means every address its handlers answer to — wider than its entities,
253
+ // since a handler without one runs behind it too.
254
+ else for (const address of new Set(frond.handlers.map((h) => h.address))) use(around, address);
255
+ }
256
+ if (frond.middlewares.length > 0) {
257
+ frondLog.debug(`${frond.middlewares.length} middleware(s): ${frond.middlewares.map((m) => `${m.name} (${m.scope})`).join(', ')}`);
258
+ }
259
+
229
260
  // Build handler facades → registered in ROOT container (public contract)
230
261
  const defaultHandlers = frond.handlers.filter((h) => !h.surface);
231
262
  const surfaceHandlers = frond.handlers.filter((h) => h.surface);
package/src/boot/ports.ts CHANGED
@@ -1,11 +1,20 @@
1
- import type { ProviderEntry } from '../descriptor/frond.js';
1
+ import { nameOf, type ProviderEntry } from '../descriptor/frond.js';
2
2
 
3
- /** A port is a class something already answers under. */
3
+ /**
4
+ * A port is a class something already answers under, and what answers it may be a CHAIN:
5
+ * the realization, wrapped by whoever stands in front of it.
6
+ *
7
+ * A wrapper is recognized by its FORM — it extends the port AND asks for it. Nothing
8
+ * declares it, the way nothing declares a realization.
9
+ *
10
+ * Returns, per port, the chain from the OUTSIDE IN: the last entry is the realization,
11
+ * every one before it wraps what follows.
12
+ */
4
13
  export function portBindings(
5
14
  providers: ProviderEntry[],
6
15
  answers: (name: string) => boolean,
7
- chosen: Record<string, string> | undefined,
8
- ): Map<string, ProviderEntry> {
16
+ chosen: Record<string, string | readonly string[]> | undefined,
17
+ ): Map<string, ProviderEntry[]> {
9
18
  // port class name → the classes that extend it, in scan order.
10
19
  const candidates = new Map<string, ProviderEntry[]>();
11
20
  for (const provider of providers) {
@@ -22,32 +31,67 @@ export function portBindings(
22
31
  candidates.set(port, [...(candidates.get(port) ?? []), provider]);
23
32
  }
24
33
 
25
- const bound = new Map<string, ProviderEntry>();
26
- for (const [port, impls] of candidates) {
27
- const named = chosen?.[port];
28
- if (named) {
29
- const pick = impls.find((i) => i.ctor.name === named);
34
+ const bound = new Map<string, ProviderEntry[]>();
35
+ for (const [port, all] of candidates) {
36
+ /** It extends the port and asks for it: it stands in front of what answers. */
37
+ const wraps = (one: ProviderEntry) => one.deps.includes(port);
38
+ const wrappers = all.filter(wraps);
39
+ const impls = all.filter((one) => !wraps(one));
40
+ const stated = chosen?.[port];
41
+
42
+ bound.set(port, chain(port, impls, wrappers, stated));
43
+ }
44
+
45
+ return bound;
46
+ }
47
+
48
+ function chain(
49
+ port: string,
50
+ impls: ProviderEntry[],
51
+ wrappers: ProviderEntry[],
52
+ stated: string | readonly string[] | undefined,
53
+ ): ProviderEntry[] {
54
+ // The whole chain, outside in — a string is a chain of one, and the last name is what
55
+ // actually answers. What a deployment wraps its realization with is the same kind of
56
+ // decision as which realization it uses, so it is the same key.
57
+ if (stated !== undefined) {
58
+ const order = typeof stated === 'string' ? [stated] : stated;
59
+ const all = [...wrappers, ...impls];
60
+
61
+ return order.map((name) => {
62
+ const pick = all.find((one) => nameOf(one) === name);
30
63
  if (!pick) {
31
64
  throw new Error(
32
- `[ports] ${port}: '${named}' does not extend it. `
33
- + `What does: ${impls.map((i) => i.ctor.name).join(', ')}.`,
65
+ `[ports] ${port}: '${name}' does not extend it. `
66
+ + `What does: ${all.map(nameOf).join(', ')}.`,
34
67
  );
35
68
  }
36
- bound.set(port, pick);
37
- continue;
38
- }
39
- if (impls.length > 1) {
40
- // Refusing rather than keeping one, for the reason `remotes` refuses two owners
41
- // of an entity: whichever won would depend on scan order, and the handler would
42
- // charge the wrong provider without a word.
43
- throw new Error(
44
- `[ports] ${impls.map((i) => i.ctor.name).join(' and ')} both extend ${port}, `
45
- + 'and nothing says which one answers it. Which realization a deployment uses is '
46
- + `not a fact about the code — state it: ports: { ${port}: '${impls[0].ctor.name}' } `
47
- + 'in fougere.config.ts.',
48
- );
49
- }
50
- bound.set(port, impls[0]);
69
+
70
+ return pick;
71
+ });
51
72
  }
52
- return bound;
73
+
74
+ if (impls.length > 1) {
75
+ // Refusing rather than keeping one, for the reason `remotes` refuses two owners
76
+ // of an entity: whichever won would depend on scan order, and the handler would
77
+ // charge the wrong provider without a word.
78
+ throw new Error(
79
+ `[ports] ${impls.map(nameOf).join(' and ')} both extend ${port}, `
80
+ + 'and nothing says which one answers it. Which realization a deployment uses is '
81
+ + `not a fact about the code — state it: ports: { ${port}: '${nameOf(impls[0]!)}' } `
82
+ + 'in fougere.config.ts.',
83
+ );
84
+ }
85
+
86
+ if (wrappers.length > 1) {
87
+ // Same refusal one layer out: two wrappers are an ORDER, and scan order is not one.
88
+ throw new Error(
89
+ `[ports] ${wrappers.map(nameOf).join(' and ')} both wrap ${port}, `
90
+ + 'and nothing says which stands in front. State the chain, outside in: '
91
+ + `ports: { ${port}: [${[...wrappers, ...impls].map((one) => `'${nameOf(one)}'`).join(', ')}] } `
92
+ + 'in fougere.config.ts.',
93
+ );
94
+ }
95
+
96
+ return [...wrappers, ...impls];
53
97
  }
@@ -2,7 +2,7 @@
2
2
  import type { FrondCall, Transport } from '../wire/call.js';
3
3
  import { assertIdentityCard, RPC_ENTITY } from '../wire/call.js';
4
4
  import { runMiddlewares, type AppMiddleware, type OperationContext } from '../wire/middleware.js';
5
- import { EMPTY_INVOCATION, type InvocationContext } from '../wire/Invocation.js';
5
+ import { type InvocationContext, Invocation } from '../wire/Invocation.js';
6
6
  import { FougereError, ErrorCode } from '../wire/errors.js';
7
7
  import { Card, type SchemaView, type SchemaDescriptor } from '@fougere/schema';
8
8
  import { dynamicOperations } from '../entry/facade.js';
@@ -43,7 +43,7 @@ export function createRemoteRouter(
43
43
  const transport = transports.get(url) ?? makeTransport(url);
44
44
  transports.set(url, transport);
45
45
  try {
46
- const answer = await transport({ entity: RPC_ENTITY, op: 'discover' }, EMPTY_INVOCATION);
46
+ const answer = await transport({ entity: RPC_ENTITY, op: 'discover' }, Invocation.empty);
47
47
  // Judged below and not here: this catch means "unreachable, retry", and a
48
48
  // refusal thrown inside it would be swallowed into another silent retry.
49
49
  return { label, url, transport, answer };
@@ -118,7 +118,7 @@ export function createRemoteFacade(
118
118
  router: RemoteRouter,
119
119
  middlewaresFor: (address: string) => AppMiddleware[],
120
120
  ): Facade {
121
- const opFn = (op: string) => async (invocation: InvocationContext = EMPTY_INVOCATION) => {
121
+ const opFn = (op: string) => async (invocation: InvocationContext = Invocation.empty) => {
122
122
  const { frond, transport } = await router.route(entity);
123
123
  const call: FrondCall = { frond, entity, op };
124
124
  const ctx: OperationContext = {
package/src/boot/types.ts CHANGED
@@ -28,6 +28,16 @@ export interface CreateAppOptions {
28
28
  sourceOf?: (entityName: string) => string;
29
29
  transacts?: (source: string) => boolean;
30
30
  transacted?: <R>(source: string, fn: (storageFactory: StorageFactory) => Promise<R>) => Promise<R>;
31
+ /**
32
+ * Bring the shape of what lives here up to date — a source's own gesture, declared on
33
+ * `Source.migrate` and handed over whole.
34
+ *
35
+ * Handed here rather than assembled by the host: four of them wrote
36
+ * `migrating(storage.migrate)` themselves, one of them as a string inside generated
37
+ * code, and eight demos wrote nothing — so they had no migration and nothing said it.
38
+ * The ASCENT is core's to order, since rows after tables is not a host's preference.
39
+ */
40
+ migrate?: (app: App) => Promise<void> | void;
31
41
  /** Whether that source refuses a constraint at the rows — the boot says so when it does not. */
32
42
  enforces?: (source: string, constraint: Constraint) => boolean;
33
43
  /** Builds the cross-source reader a frond gets when it declares `reads:`. */
@@ -47,7 +57,12 @@ export interface CreateAppOptions {
47
57
  * Which realization answers which port — see `FougereConfig.ports`. Needed only
48
58
  * when two classes extend the same port; one is resolved by convention.
49
59
  */
50
- ports?: Record<string, string>;
60
+ /**
61
+ * What answers a port. A string names the realization; a LIST is the chain, from the
62
+ * outside in — `['Retrying', 'Stripe']` puts `Retrying` in front of `Stripe`, and the
63
+ * last name is what actually charges.
64
+ */
65
+ ports?: Record<string, string | readonly string[]>;
51
66
  /** What this app takes on beyond its fronds, each stating what it does and what it undoes. */
52
67
  extensions?: readonly (Extension | undefined)[];
53
68
  /**
@@ -0,0 +1,38 @@
1
+ import { entity, oneOf, text, json, list, optional, created } from '@fougere/schema';
2
+
3
+ /**
4
+ * One line. A fact about the process, announced like any other — which is what makes a
5
+ * destination an ordinary handler and `remotes:` the only thing that decides where it runs.
6
+ *
7
+ * Here and not in `@fougere/log`, because the BOOT announces: core would otherwise have to
8
+ * name an optional package's entity to reach its own lines. What is optional is where a
9
+ * line GOES, and that is what `@fougere/log` ships.
10
+ *
11
+ * No `primary()`: an id costs 87 µs to generate (cuid2), and nothing addresses a line
12
+ * by one. `at` is `created()`, so announcing is what fills it.
13
+ */
14
+ export default class LogLine extends entity({
15
+ level: oneOf('debug', 'info', 'warn', 'error'),
16
+ /** Who wrote it — 'app', 'app:catalog'. */
17
+ name: text(),
18
+ message: text({ min: 1 }),
19
+ /** What the writer passed beside the message — most lines pass none. */
20
+ args: optional(list(json())),
21
+ at: created(),
22
+ }) {}
23
+
24
+ /** The fact a boot announces, spelled once. */
25
+ export const LOG_LINE = 'logLine';
26
+
27
+ /**
28
+ * The addresses whose operations CARRY a line — every destination this app installed.
29
+ *
30
+ * Whatever carries a fact must not produce one, or the emission refuses it by name:
31
+ * keeping a line is a DISPATCH, so logging it announces a line inside the announcement of
32
+ * one. FILLED BY THE BOOT from what binds `logLine`, not written down — a hard-coded list
33
+ * cannot know a third party's destination, and the app already knows who subscribed.
34
+ *
35
+ * Read by `loggerMiddleware` and by `observability`'s `trace()`, both of which observe
36
+ * every operation and would otherwise observe the observation.
37
+ */
38
+ export const CARRIES_LINE = new Set<string>();