@fougere/core 0.10.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.
- package/dist/CallIdentity.d.ts.map +1 -1
- package/dist/CallIdentity.js +2 -0
- package/dist/CallIdentity.js.map +1 -1
- package/dist/Constraint.d.ts +1 -1
- package/dist/Constraint.d.ts.map +1 -1
- package/dist/FougereConfig.d.ts +10 -6
- package/dist/FougereConfig.d.ts.map +1 -1
- package/dist/FougereConfig.js +19 -4
- package/dist/FougereConfig.js.map +1 -1
- package/dist/FrondConfig.d.ts +0 -6
- package/dist/FrondConfig.d.ts.map +1 -1
- package/dist/FrondConfig.js.map +1 -1
- package/dist/FrondDeclaration.d.ts +2 -9
- package/dist/FrondDeclaration.d.ts.map +1 -1
- package/dist/FrondDeclaration.js +0 -1
- package/dist/FrondDeclaration.js.map +1 -1
- package/dist/FrondsStated.d.ts +54 -0
- package/dist/FrondsStated.d.ts.map +1 -0
- package/dist/FrondsStated.js +26 -0
- package/dist/FrondsStated.js.map +1 -0
- package/dist/Source.d.ts.map +1 -1
- package/dist/Source.js +4 -3
- package/dist/Source.js.map +1 -1
- package/dist/StatedFrond.d.ts +21 -0
- package/dist/StatedFrond.d.ts.map +1 -0
- package/dist/StatedFrond.js +34 -0
- package/dist/StatedFrond.js.map +1 -0
- package/dist/StatedModules.d.ts +19 -0
- package/dist/StatedModules.d.ts.map +1 -0
- package/dist/StatedModules.js +37 -0
- package/dist/StatedModules.js.map +1 -0
- package/dist/boot/CreateAppOptions.d.ts +19 -2
- package/dist/boot/CreateAppOptions.d.ts.map +1 -1
- package/dist/boot/Emissions.d.ts +9 -0
- package/dist/boot/Emissions.d.ts.map +1 -1
- package/dist/boot/Emissions.js +11 -5
- package/dist/boot/Emissions.js.map +1 -1
- package/dist/boot/Hosting.d.ts +38 -0
- package/dist/boot/Hosting.d.ts.map +1 -0
- package/dist/boot/Hosting.js +2 -0
- package/dist/boot/Hosting.js.map +1 -0
- package/dist/boot/Peer.d.ts +16 -0
- package/dist/boot/Peer.d.ts.map +1 -0
- package/dist/boot/Peer.js +2 -0
- package/dist/boot/Peer.js.map +1 -0
- package/dist/boot/bootstrap.d.ts.map +1 -1
- package/dist/boot/bootstrap.js +372 -235
- package/dist/boot/bootstrap.js.map +1 -1
- package/dist/boot/declared.d.ts.map +1 -1
- package/dist/boot/declared.js +38 -3
- package/dist/boot/declared.js.map +1 -1
- package/dist/boot/frame.d.ts +0 -4
- package/dist/boot/frame.d.ts.map +1 -1
- package/dist/boot/frame.js +31 -14
- package/dist/boot/frame.js.map +1 -1
- package/dist/boot/hosted.d.ts.map +1 -1
- package/dist/boot/hosted.js +4 -3
- package/dist/boot/hosted.js.map +1 -1
- package/dist/boot/install.d.ts +19 -0
- package/dist/boot/install.d.ts.map +1 -1
- package/dist/boot/install.js +273 -178
- package/dist/boot/install.js.map +1 -1
- package/dist/boot/nesting.d.ts +33 -0
- package/dist/boot/nesting.d.ts.map +1 -0
- package/dist/boot/nesting.js +164 -0
- package/dist/boot/nesting.js.map +1 -0
- package/dist/boot/ownership.d.ts +0 -1
- package/dist/boot/ownership.d.ts.map +1 -1
- package/dist/boot/ownership.js +66 -51
- package/dist/boot/ownership.js.map +1 -1
- package/dist/boot/peerOver.d.ts +4 -0
- package/dist/boot/peerOver.d.ts.map +1 -0
- package/dist/boot/peerOver.js +12 -0
- package/dist/boot/peerOver.js.map +1 -0
- package/dist/boot/ports.js +30 -25
- package/dist/boot/ports.js.map +1 -1
- package/dist/boot/relations.d.ts +54 -0
- package/dist/boot/relations.d.ts.map +1 -0
- package/dist/boot/relations.js +150 -0
- package/dist/boot/relations.js.map +1 -0
- package/dist/boot/remote.d.ts.map +1 -1
- package/dist/boot/remote.js +31 -28
- package/dist/boot/remote.js.map +1 -1
- package/dist/boot/seed.d.ts.map +1 -1
- package/dist/boot/seed.js +27 -20
- package/dist/boot/seed.js.map +1 -1
- package/dist/boot/together.d.ts +3 -0
- package/dist/boot/together.d.ts.map +1 -1
- package/dist/boot/together.js +72 -56
- package/dist/boot/together.js.map +1 -1
- package/dist/contract.d.ts +1 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/descriptor/FrondDescriptor.d.ts +7 -0
- package/dist/descriptor/FrondDescriptor.d.ts.map +1 -1
- package/dist/descriptor/MiddlewareEntry.d.ts +6 -5
- package/dist/descriptor/MiddlewareEntry.d.ts.map +1 -1
- package/dist/descriptor/ProviderEntry.d.ts +7 -0
- package/dist/descriptor/ProviderEntry.d.ts.map +1 -1
- package/dist/dispatch/Dependent.d.ts +19 -0
- package/dist/dispatch/Dependent.d.ts.map +1 -0
- package/dist/dispatch/Dependent.js +2 -0
- package/dist/dispatch/Dependent.js.map +1 -0
- package/dist/dispatch/Dispatcher.d.ts +14 -1
- package/dist/dispatch/Dispatcher.d.ts.map +1 -1
- package/dist/dispatch/Dispatcher.js +36 -8
- package/dist/dispatch/Dispatcher.js.map +1 -1
- package/dist/dispatch/HandlerFacade.d.ts +6 -1
- package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.js +23 -20
- package/dist/dispatch/HandlerFacade.js.map +1 -1
- package/dist/dispatch/Journal.d.ts +25 -0
- package/dist/dispatch/Journal.d.ts.map +1 -0
- package/dist/dispatch/Journal.js +3 -0
- package/dist/dispatch/Journal.js.map +1 -0
- package/dist/dispatch/PresenterExecutor.d.ts +6 -0
- package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
- package/dist/dispatch/PresenterExecutor.js +30 -22
- package/dist/dispatch/PresenterExecutor.js.map +1 -1
- package/dist/dispatch/RelationCheck.d.ts +22 -0
- package/dist/dispatch/RelationCheck.d.ts.map +1 -0
- package/dist/dispatch/RelationCheck.js +2 -0
- package/dist/dispatch/RelationCheck.js.map +1 -0
- package/dist/dispatch/Release.d.ts +36 -0
- package/dist/dispatch/Release.d.ts.map +1 -0
- package/dist/dispatch/Release.js +130 -0
- package/dist/dispatch/Release.js.map +1 -0
- package/dist/dispatch/StorageGuard.d.ts +39 -1
- package/dist/dispatch/StorageGuard.d.ts.map +1 -1
- package/dist/dispatch/StorageGuard.js +128 -54
- package/dist/dispatch/StorageGuard.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +2 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +2 -1
- package/dist/node.js.map +1 -1
- package/dist/storage/Storage.js +1 -1
- package/dist/storage/Storage.js.map +1 -1
- package/dist/storage/Store.d.ts.map +1 -1
- package/dist/storage/Store.js +29 -21
- package/dist/storage/Store.js.map +1 -1
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +14 -0
- package/dist/verify.js.map +1 -1
- package/dist/wire/Emit.d.ts +9 -0
- package/dist/wire/Emit.d.ts.map +1 -1
- package/dist/wire/Emit.js +16 -0
- package/dist/wire/Emit.js.map +1 -1
- package/dist/wire/Invocation.d.ts +3 -2
- package/dist/wire/Invocation.d.ts.map +1 -1
- package/dist/wire/Invocation.js +5 -2
- package/dist/wire/Invocation.js.map +1 -1
- package/dist/wire/InvocationContext.d.ts +2 -0
- package/dist/wire/InvocationContext.d.ts.map +1 -1
- package/dist/wire/SignedCall.d.ts +2 -0
- package/dist/wire/SignedCall.d.ts.map +1 -1
- package/dist/wire/binding.d.ts.map +1 -1
- package/dist/wire/binding.js +29 -57
- package/dist/wire/binding.js.map +1 -1
- package/dist/wire/drift.d.ts.map +1 -1
- package/dist/wire/drift.js +22 -19
- package/dist/wire/drift.js.map +1 -1
- package/package.json +4 -4
- package/src/CallIdentity.ts +2 -0
- package/src/Constraint.ts +1 -1
- package/src/FougereConfig.ts +24 -10
- package/src/FrondConfig.ts +0 -6
- package/src/FrondDeclaration.ts +2 -7
- package/src/FrondsStated.ts +72 -0
- package/src/Source.ts +3 -2
- package/src/StatedFrond.ts +51 -0
- package/src/StatedModules.ts +58 -0
- package/src/boot/CreateAppOptions.ts +19 -2
- package/src/boot/Emissions.ts +16 -5
- package/src/boot/Hosting.ts +38 -0
- package/src/boot/Peer.ts +15 -0
- package/src/boot/bootstrap.ts +512 -271
- package/src/boot/declared.ts +38 -3
- package/src/boot/frame.ts +42 -11
- package/src/boot/hosted.ts +4 -3
- package/src/boot/install.ts +400 -203
- package/src/boot/nesting.ts +196 -0
- package/src/boot/ownership.ts +79 -51
- package/src/boot/peerOver.ts +17 -0
- package/src/boot/ports.ts +45 -31
- package/src/boot/relations.ts +172 -0
- package/src/boot/remote.ts +47 -29
- package/src/boot/seed.ts +36 -27
- package/src/boot/together.ts +102 -63
- package/src/contract.ts +1 -0
- package/src/descriptor/FrondDescriptor.ts +7 -0
- package/src/descriptor/MiddlewareEntry.ts +6 -5
- package/src/descriptor/ProviderEntry.ts +7 -0
- package/src/dispatch/Dependent.ts +19 -0
- package/src/dispatch/Dispatcher.ts +43 -7
- package/src/dispatch/HandlerFacade.ts +27 -17
- package/src/dispatch/Journal.ts +26 -0
- package/src/dispatch/PresenterExecutor.ts +33 -22
- package/src/dispatch/RelationCheck.ts +21 -0
- package/src/dispatch/Release.ts +180 -0
- package/src/dispatch/StorageGuard.ts +138 -55
- package/src/index.ts +8 -0
- package/src/node.ts +2 -1
- package/src/storage/Storage.ts +2 -2
- package/src/storage/Store.ts +39 -18
- package/src/verify.ts +15 -0
- package/src/wire/Emit.ts +23 -0
- package/src/wire/Invocation.ts +5 -2
- package/src/wire/InvocationContext.ts +2 -0
- package/src/wire/SignedCall.ts +2 -0
- package/src/wire/binding.ts +26 -58
- package/src/wire/drift.ts +48 -21
package/src/boot/remote.ts
CHANGED
|
@@ -65,36 +65,9 @@ export function createRemoteRouter(
|
|
|
65
65
|
|
|
66
66
|
for (const answered of cards) {
|
|
67
67
|
if (!answered) continue;
|
|
68
|
-
const { label, url, transport, answer } = answered;
|
|
69
|
-
pending.delete(label);
|
|
70
|
-
const card = assertIdentityCard(answer, `Remote '${label}' (${url})`);
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// adding one here would answer a call with a transport to a facade that
|
|
75
|
-
// does not exist.
|
|
76
|
-
for (const facade of frond.facades) {
|
|
77
|
-
const first = claimedBy.get(facade.name);
|
|
78
|
-
/** Two remotes claiming one name is refused, not silently arbitrated. */
|
|
79
|
-
if (first !== undefined && first !== label) {
|
|
80
|
-
throw new FougereError({
|
|
81
|
-
code: ErrorCode.INTERNAL_ERROR,
|
|
82
|
-
message:
|
|
83
|
-
`[claim] Two remotes serve '${facade.name}': '${first}' and '${label}'.\n`
|
|
84
|
-
+ ` A call names an entity, not a frond, so nothing could choose between them.\n`
|
|
85
|
-
+ ` - Keep one of the two out of \`remotes:\`, or\n`
|
|
86
|
-
+ ` - expose one of them under a different entity name.`,
|
|
87
|
-
entity: facade.name,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
claimedBy.set(facade.name, label);
|
|
91
|
-
byEntity.set(facade.name, {
|
|
92
|
-
frond: frond.name,
|
|
93
|
-
transport,
|
|
94
|
-
...(facade.schema ? { schema: Card.fromDescriptor(facade.schema as SchemaDescriptor).toSchema() } : {}),
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
}
|
|
69
|
+
pending.delete(answered.label);
|
|
70
|
+
claimFacades(answered, byEntity, claimedBy);
|
|
98
71
|
}
|
|
99
72
|
};
|
|
100
73
|
|
|
@@ -141,3 +114,48 @@ export function createRemoteFacade(
|
|
|
141
114
|
|
|
142
115
|
return dynamicOperations(opFn) as Facade;
|
|
143
116
|
}
|
|
117
|
+
|
|
118
|
+
/** What one remote answered `discover` with, once it has been reached. */
|
|
119
|
+
interface Answered {
|
|
120
|
+
label: string;
|
|
121
|
+
url: string;
|
|
122
|
+
transport: Transport;
|
|
123
|
+
answer: unknown;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Which remote serves which facade. Facades only: a fact is not routable — nobody calls it, it
|
|
128
|
+
* arrives — so adding one here would answer a call with a transport to a facade that does not
|
|
129
|
+
* exist. Two remotes claiming one name is refused, never silently arbitrated.
|
|
130
|
+
*/
|
|
131
|
+
function claimFacades(
|
|
132
|
+
{ label, url, transport, answer }: Answered,
|
|
133
|
+
byEntity: Map<string, Route>,
|
|
134
|
+
claimedBy: Map<string, string>,
|
|
135
|
+
): void {
|
|
136
|
+
const card = assertIdentityCard(answer, `Remote '${label}' (${url})`);
|
|
137
|
+
|
|
138
|
+
for (const frond of card.fronds) {
|
|
139
|
+
for (const facade of frond.facades) {
|
|
140
|
+
const first = claimedBy.get(facade.name);
|
|
141
|
+
if (first !== undefined && first !== label) {
|
|
142
|
+
throw new FougereError({
|
|
143
|
+
code: ErrorCode.INTERNAL_ERROR,
|
|
144
|
+
message:
|
|
145
|
+
`[claim] Two remotes serve '${facade.name}': '${first}' and '${label}'.\n`
|
|
146
|
+
+ ` A call names an entity, not a frond, so nothing could choose between them.\n`
|
|
147
|
+
+ ` - Keep one of the two out of \`remotes:\`, or\n`
|
|
148
|
+
+ ` - expose one of them under a different entity name.`,
|
|
149
|
+
entity: facade.name,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
claimedBy.set(facade.name, label);
|
|
154
|
+
byEntity.set(facade.name, {
|
|
155
|
+
frond: frond.name,
|
|
156
|
+
transport,
|
|
157
|
+
...(facade.schema ? { schema: Card.fromDescriptor(facade.schema as SchemaDescriptor).toSchema() } : {}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/boot/seed.ts
CHANGED
|
@@ -15,6 +15,33 @@ export interface SeedOrder {
|
|
|
15
15
|
|
|
16
16
|
/** Seeds in dependency order — a `ref()` target is planted before its referrer. */
|
|
17
17
|
export function orderSeeds(fronds: FrondDescriptor[]): SeedOrder {
|
|
18
|
+
const seeds = fronds.flatMap((frond) => frond.seeds);
|
|
19
|
+
const waiting = whatEachWaitsFor(fronds, seeds);
|
|
20
|
+
|
|
21
|
+
const ordered: SeedEntry[] = [];
|
|
22
|
+
const planted = new Set<string>();
|
|
23
|
+
|
|
24
|
+
while (waiting.size > 0) {
|
|
25
|
+
const ready = [...waiting.keys()].find((seed) => [...waiting.get(seed)!].every((dep) => planted.has(dep)));
|
|
26
|
+
if (!ready) break;
|
|
27
|
+
|
|
28
|
+
ordered.push(ready);
|
|
29
|
+
planted.add(ready.entityName.toLowerCase());
|
|
30
|
+
waiting.delete(ready);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { ordered, cycle: [...waiting.keys()] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Only what is actually SEEDED can be waited for: a relation to an entity with no seed is
|
|
38
|
+
* already satisfied by whatever put its rows there, and an entity naming itself waits for
|
|
39
|
+
* nobody.
|
|
40
|
+
*/
|
|
41
|
+
function whatEachWaitsFor(
|
|
42
|
+
fronds: FrondDescriptor[],
|
|
43
|
+
seeds: SeedEntry[],
|
|
44
|
+
): Map<SeedEntry, Set<string>> {
|
|
18
45
|
const refs = new Map<string, Set<string>>();
|
|
19
46
|
for (const frond of fronds) {
|
|
20
47
|
for (const entity of frond.entities) {
|
|
@@ -28,33 +55,13 @@ export function orderSeeds(fronds: FrondDescriptor[]): SeedOrder {
|
|
|
28
55
|
}
|
|
29
56
|
}
|
|
30
57
|
|
|
31
|
-
const seeds = fronds.flatMap((frond) => frond.seeds);
|
|
32
58
|
const seeded = new Set(seeds.map((seed) => seed.entityName.toLowerCase()));
|
|
33
59
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const waiting = new Map(
|
|
37
|
-
seeds.map((seed) => {
|
|
38
|
-
const own = seed.entityName.toLowerCase();
|
|
39
|
-
const targets = [...(refs.get(own) ?? [])].filter((target) => seeded.has(target) && target !== own);
|
|
60
|
+
return new Map(seeds.map((seed) => {
|
|
61
|
+
const own = seed.entityName.toLowerCase();
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
const ordered: SeedEntry[] = [];
|
|
46
|
-
const planted = new Set<string>();
|
|
47
|
-
|
|
48
|
-
while (waiting.size > 0) {
|
|
49
|
-
const ready = [...waiting.keys()].find((seed) => [...waiting.get(seed)!].every((dep) => planted.has(dep)));
|
|
50
|
-
if (!ready) break;
|
|
51
|
-
|
|
52
|
-
ordered.push(ready);
|
|
53
|
-
planted.add(ready.entityName.toLowerCase());
|
|
54
|
-
waiting.delete(ready);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { ordered, cycle: [...waiting.keys()] };
|
|
63
|
+
return [seed, new Set([...(refs.get(own) ?? [])].filter((target) => seeded.has(target) && target !== own))] as const;
|
|
64
|
+
}));
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/** Where a seed writes, and what it may skip — resolved per entity. */
|
|
@@ -110,10 +117,12 @@ function facadeFor(app: App, entityName: string): SeedFacade | undefined {
|
|
|
110
117
|
let handler: Record<string, Function> | undefined;
|
|
111
118
|
try { handler = app.resolve<Record<string, Function>>(facadeKeyOf(entityName)); } catch {}
|
|
112
119
|
|
|
113
|
-
|
|
120
|
+
const list = handler?.list;
|
|
121
|
+
const create = handler?.create;
|
|
122
|
+
if (typeof list === 'function' && typeof create === 'function') {
|
|
114
123
|
return {
|
|
115
|
-
list: () =>
|
|
116
|
-
write: (item) =>
|
|
124
|
+
list: () => list.call(handler) as Promise<unknown[]>,
|
|
125
|
+
write: (item) => create.call(handler, { params: {}, query: {}, input: item, state: {} }),
|
|
117
126
|
};
|
|
118
127
|
}
|
|
119
128
|
|
package/src/boot/together.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { type StorageFactory } from '../storage/StorageFactory.js';
|
|
|
11
11
|
import type { Logger } from '../builtin/Logger.js';
|
|
12
12
|
import type { ProviderEntry } from '../descriptor/ProviderEntry.js';
|
|
13
13
|
import { StorageGuard } from '../dispatch/StorageGuard.js';
|
|
14
|
+
import { heldBy, releasing } from './relations.js';
|
|
15
|
+
import type { Hosting } from './Hosting.js';
|
|
14
16
|
import { recording, unwind, type Undo } from './frame.js';
|
|
15
17
|
import type { Diagnostic } from '../diagnostic.js';
|
|
16
18
|
|
|
@@ -26,6 +28,8 @@ export interface FrameWorld {
|
|
|
26
28
|
/** Whether that source hands one out — asked before the frame is built, not at the call. */
|
|
27
29
|
transacts?: (source: string) => boolean;
|
|
28
30
|
transacted?: <R>(source: string, fn: (storageFactory: StorageFactory) => Promise<R>) => Promise<R>;
|
|
31
|
+
/** What a member's references ask before a write — the answer its frond's own storage gets. */
|
|
32
|
+
hosting: Hosting;
|
|
29
33
|
log: Logger;
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -44,42 +48,59 @@ function resolve(
|
|
|
44
48
|
refused: Diagnostic[],
|
|
45
49
|
): Members | undefined {
|
|
46
50
|
const before = refused.length;
|
|
51
|
+
const entities = entitiesNamed(names.entities, world, asked, refused);
|
|
52
|
+
const providers = providersNamed(names.providers, declared, asked, refused);
|
|
47
53
|
|
|
48
|
-
|
|
54
|
+
return refused.length === before ? { entities, providers } : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The first list names ENTITIES — a class of this frond goes in the second. */
|
|
58
|
+
function entitiesNamed(
|
|
59
|
+
named: readonly string[],
|
|
60
|
+
world: FrameWorld,
|
|
61
|
+
asked: Asked,
|
|
62
|
+
refused: Diagnostic[],
|
|
63
|
+
): { name: string; schema: SchemaView }[] {
|
|
64
|
+
return named.flatMap((member) => {
|
|
49
65
|
const name = lowerFirst(member);
|
|
50
66
|
const schema = world.entityByName.get(name);
|
|
51
|
-
if (
|
|
52
|
-
refused.push({
|
|
53
|
-
...asked,
|
|
54
|
-
code: 'together-entity-unknown',
|
|
55
|
-
message: `Together<[…${member}…]>: no entity named '${member}' is scanned in this app. `
|
|
56
|
-
+ 'The first list names entities; a class of this frond goes in the second.',
|
|
57
|
-
});
|
|
67
|
+
if (schema) return [{ name, schema }];
|
|
58
68
|
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
refused.push({
|
|
70
|
+
...asked,
|
|
71
|
+
code: 'together-entity-unknown',
|
|
72
|
+
message: `Together<[…${member}…]>: no entity named '${member}' is scanned in this app. `
|
|
73
|
+
+ 'The first list names entities; a class of this frond goes in the second.',
|
|
74
|
+
});
|
|
61
75
|
|
|
62
|
-
return [
|
|
76
|
+
return [];
|
|
63
77
|
});
|
|
78
|
+
}
|
|
64
79
|
|
|
65
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The second list names the classes to REBUILD inside the frame — a service, a mirror, a
|
|
82
|
+
* repository — so that what they write is covered by the unwind.
|
|
83
|
+
*/
|
|
84
|
+
function providersNamed(
|
|
85
|
+
named: readonly string[],
|
|
86
|
+
declared: readonly ProviderEntry[],
|
|
87
|
+
asked: Asked,
|
|
88
|
+
refused: Diagnostic[],
|
|
89
|
+
): ProviderEntry[] {
|
|
90
|
+
return named.flatMap((member) => {
|
|
66
91
|
const entry = declared.find((provider) => provider.ctor.name === member);
|
|
67
|
-
if (
|
|
68
|
-
refused.push({
|
|
69
|
-
...asked,
|
|
70
|
-
code: 'together-provider-unknown',
|
|
71
|
-
message: `Together<[…], [… ${member} …]>: this frond declares no class named '${member}'. `
|
|
72
|
-
+ 'The second list names providers to rebuild inside the frame — a service, a mirror, '
|
|
73
|
-
+ 'a repository — so that what they write is covered by the unwind.',
|
|
74
|
-
});
|
|
92
|
+
if (entry) return [entry];
|
|
75
93
|
|
|
76
|
-
|
|
77
|
-
|
|
94
|
+
refused.push({
|
|
95
|
+
...asked,
|
|
96
|
+
code: 'together-provider-unknown',
|
|
97
|
+
message: `Together<[…], [… ${member} …]>: this frond declares no class named '${member}'. `
|
|
98
|
+
+ 'The second list names providers to rebuild inside the frame — a service, a mirror, '
|
|
99
|
+
+ 'a repository — so that what they write is covered by the unwind.',
|
|
100
|
+
});
|
|
78
101
|
|
|
79
|
-
return [
|
|
102
|
+
return [];
|
|
80
103
|
});
|
|
81
|
-
|
|
82
|
-
return refused.length === before ? { entities, providers } : undefined;
|
|
83
104
|
}
|
|
84
105
|
|
|
85
106
|
/** Who asked for the frame, and where they wrote it — the same three fields every refusal here carries. */
|
|
@@ -156,11 +177,14 @@ export function registerFrames(
|
|
|
156
177
|
): void {
|
|
157
178
|
let registered = 0;
|
|
158
179
|
const seen = new Set<string>();
|
|
180
|
+
|
|
159
181
|
for (const { key, filePath } of wanted) {
|
|
160
182
|
if (seen.has(key)) continue;
|
|
161
183
|
seen.add(key);
|
|
184
|
+
|
|
162
185
|
const names = membersOfTogetherKey(key);
|
|
163
186
|
if (!names) continue;
|
|
187
|
+
|
|
164
188
|
registered += 1;
|
|
165
189
|
const asked = { severity: 'blocking', filePath, subject: key } as const;
|
|
166
190
|
if (!world.storageFactory) {
|
|
@@ -175,47 +199,12 @@ export function registerFrames(
|
|
|
175
199
|
const members = resolve(names, providers, world, asked, refused);
|
|
176
200
|
// Nothing left to build a frame out of, and the refusals above already say what is missing.
|
|
177
201
|
if (!members) continue;
|
|
202
|
+
|
|
178
203
|
remoteMembers(members, world, asked, refused);
|
|
179
204
|
uncoveredWrites(members, world, asked, refused);
|
|
180
|
-
|
|
181
|
-
const sources = new Set(members.entities.map((member) => world.sourceOf?.(member.name) ?? 'db'));
|
|
182
|
-
const validator = (storage: Storage, name: string, schema: SchemaView) =>
|
|
183
|
-
new StorageGuard(schema.getFields(), name).guard(storage);
|
|
184
|
-
|
|
185
|
-
// One engine and a way into it: the engine gives the unwind AND the isolation. The
|
|
186
|
-
// question goes to the source these members live in — a composition answering for the
|
|
187
|
-
// default one would compensate a frame whose own engine holds transactions.
|
|
188
|
-
const source = sources.size === 1 ? [...sources][0]! : undefined;
|
|
189
|
-
if (world.transacted && source !== undefined && (world.transacts?.(source) ?? true)) {
|
|
190
|
-
world.log.info(`${key} — transaction, source '${source}'`);
|
|
191
|
-
scope.registerValue(key, {
|
|
192
|
-
run: <R>(fn: (entities: never, providers: never) => Promise<R>) =>
|
|
193
|
-
ambient.enterFrame(key, () =>
|
|
194
|
-
world.transacted!(source, (factory) => inScope(scope, members, factory, validator, fn as never))),
|
|
195
|
-
});
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Split, or an engine that hands out no transaction: the frame keeps the before-image
|
|
200
|
-
// of every write and replays the inverses itself. `validator` stays OUTSIDE `recording`, so
|
|
201
|
-
// a write the entity refuses never enters the journal.
|
|
202
|
-
const why = sources.size > 1
|
|
203
|
-
? members.entities.map((m) => `${m.name} in '${world.sourceOf?.(m.name) ?? 'db'}'`).join(', ')
|
|
204
|
-
: 'this storage hands out no transaction';
|
|
205
|
-
world.log.info(`${key} — compensated: ${why} — no isolation`);
|
|
206
|
-
scope.registerValue(key, {
|
|
207
|
-
run: <R>(fn: (entities: never, providers: never) => Promise<R>): Promise<R> => ambient.enterFrame(key, async () => {
|
|
208
|
-
const journal: Undo[] = [];
|
|
209
|
-
const record = (storage: Storage, name: string, schema: SchemaView) =>
|
|
210
|
-
validator(recording(storage, name, schema, journal), name, schema);
|
|
211
|
-
try {
|
|
212
|
-
return await inScope(scope, members, world.storageFactory!, record, fn as never);
|
|
213
|
-
} catch (cause) {
|
|
214
|
-
return unwind(journal, cause, world.log);
|
|
215
|
-
}
|
|
216
|
-
}),
|
|
217
|
-
});
|
|
205
|
+
scope.registerValue(key, frameOver(key, members, scope, world));
|
|
218
206
|
}
|
|
207
|
+
|
|
219
208
|
if (registered > 0 && ambient.degraded) {
|
|
220
209
|
world.log.warn(
|
|
221
210
|
'no async context on this runtime — frames run one at a time, and a frame opened '
|
|
@@ -223,3 +212,53 @@ export function registerFrames(
|
|
|
223
212
|
);
|
|
224
213
|
}
|
|
225
214
|
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A transaction when one engine holds every member, a compensation otherwise.
|
|
218
|
+
*
|
|
219
|
+
* One engine and a way into it: the engine gives the unwind AND the isolation. The question goes
|
|
220
|
+
* to the SOURCE these members live in — a composition answering for the default one would
|
|
221
|
+
* compensate a frame whose own engine holds transactions.
|
|
222
|
+
*/
|
|
223
|
+
function frameOver(key: string, members: Members, scope: Container, world: FrameWorld): Frame {
|
|
224
|
+
const sources = new Set(members.entities.map((member) => world.sourceOf?.(member.name) ?? 'db'));
|
|
225
|
+
const validator = (storage: Storage, name: string, schema: SchemaView) =>
|
|
226
|
+
new StorageGuard(schema.getFields(), name, {}, heldBy(schema, name, world.hosting), releasing(world.hosting)).guard(storage);
|
|
227
|
+
|
|
228
|
+
const source = sources.size === 1 ? [...sources][0]! : undefined;
|
|
229
|
+
if (world.transacted && source !== undefined && (world.transacts?.(source) ?? true)) {
|
|
230
|
+
world.log.info(`${key} — transaction, source '${source}'`);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
run: <R>(fn: (entities: never, providers: never) => Promise<R>) =>
|
|
234
|
+
ambient.enterFrame(key, () =>
|
|
235
|
+
world.transacted!(source, (factory) => inScope(scope, members, factory, validator, fn as never))),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Split, or an engine that hands out no transaction: the frame keeps the before-image of every
|
|
240
|
+
// write and replays the inverses itself. `validator` stays OUTSIDE `recording`, so a write the
|
|
241
|
+
// entity refuses never enters the journal.
|
|
242
|
+
const why = sources.size > 1
|
|
243
|
+
? members.entities.map((one) => `${one.name} in '${world.sourceOf?.(one.name) ?? 'db'}'`).join(', ')
|
|
244
|
+
: 'this storage hands out no transaction';
|
|
245
|
+
world.log.info(`${key} — compensated: ${why} — no isolation`);
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
run: <R>(fn: (entities: never, providers: never) => Promise<R>): Promise<R> => ambient.enterFrame(key, async () => {
|
|
249
|
+
const journal: Undo[] = [];
|
|
250
|
+
const record = (storage: Storage, name: string, schema: SchemaView) =>
|
|
251
|
+
validator(recording(storage, name, schema, journal), name, schema);
|
|
252
|
+
try {
|
|
253
|
+
return await inScope(scope, members, world.storageFactory!, record, fn as never);
|
|
254
|
+
} catch (cause) {
|
|
255
|
+
return unwind(journal, cause, world.log);
|
|
256
|
+
}
|
|
257
|
+
}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** What a `Together<[…]>` key resolves to — the block, and nothing else. */
|
|
262
|
+
interface Frame {
|
|
263
|
+
run<R>(fn: (entities: never, providers: never) => Promise<R>): Promise<R>;
|
|
264
|
+
}
|
package/src/contract.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type { Comparison } from './storage/Comparison.js';
|
|
|
18
18
|
export { toPublicError } from './wire/http-error.js';
|
|
19
19
|
export { Invocation } from './wire/Invocation.js';
|
|
20
20
|
export type { InvocationContext } from './wire/InvocationContext.js';
|
|
21
|
+
export type { PartialInvocation } from './wire/PartialInvocation.js';
|
|
21
22
|
export { Call } from './wire/Call.js';
|
|
22
23
|
export { RouteAddress } from './wire/RouteAddress.js';
|
|
23
24
|
export type { FrondCall } from './wire/FrondCall.js';
|
|
@@ -34,6 +34,13 @@ export interface FrondDescriptor {
|
|
|
34
34
|
* domain. Set by the boot, never by a declaration.
|
|
35
35
|
*/
|
|
36
36
|
brought?: true;
|
|
37
|
+
/**
|
|
38
|
+
* The frond this one inherits code from — its scope hangs off that one's, so a service, a
|
|
39
|
+
* repository, a port or a middleware declared there answers here too. The same word the
|
|
40
|
+
* config uses, set by the boot from `FougereConfig.fronds` and never by a scan: the disk is
|
|
41
|
+
* flat, and a directory says nothing about who shares its code.
|
|
42
|
+
*/
|
|
43
|
+
extends?: string;
|
|
37
44
|
/** The ops that finish a fact, in order — see `FrondConfig.pipes`. */
|
|
38
45
|
pipes?: Record<string, string[]>;
|
|
39
46
|
/**
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A discovered middleware — a class declaring `around(ctx, next)`, which runs before and
|
|
3
|
-
*
|
|
2
|
+
* A discovered middleware — a class declaring `around(ctx, next)`, which runs before and after
|
|
3
|
+
* every operation of its frond, and of the fronds that inherit from it.
|
|
4
|
+
*
|
|
5
|
+
* How far it reaches is not written here: it is where the frond sits in `FougereConfig.fronds`,
|
|
6
|
+
* and stating it twice would put one decision in two places.
|
|
4
7
|
*/
|
|
5
8
|
export interface MiddlewareEntry {
|
|
6
|
-
/** Class name —
|
|
9
|
+
/** Class name — the key it registers under in its frond's scope. */
|
|
7
10
|
name: string;
|
|
8
11
|
/** The middleware class. */
|
|
9
12
|
ctor: new (...args: never[]) => unknown;
|
|
10
|
-
/** How far it reaches: its own frond's entities, or every operation in the process. */
|
|
11
|
-
scope: 'frond' | 'app';
|
|
12
13
|
/** Constructor dependency type names (from AST scan). */
|
|
13
14
|
deps: string[];
|
|
14
15
|
/** Absolute file path (for debugging). */
|
|
@@ -15,4 +15,11 @@ export interface ProviderEntry {
|
|
|
15
15
|
deps: string[];
|
|
16
16
|
/** Absolute file path (for debugging). */
|
|
17
17
|
filePath: string;
|
|
18
|
+
/**
|
|
19
|
+
* It stated `implements AsyncDisposable` — so there is ONE of it per frond, and that frond's
|
|
20
|
+
* scope closes it. The language's own marker, which `App` already answers; a provider that
|
|
21
|
+
* says nothing is built per consumer and closed by nobody, which is right for one that holds
|
|
22
|
+
* nothing.
|
|
23
|
+
*/
|
|
24
|
+
kept?: true;
|
|
18
25
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { OnDelete } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A `ref()` read from the TARGET's side: rows that name it, and what becomes of them.
|
|
5
|
+
*
|
|
6
|
+
* The dual of `RelationCheck`, which reads the same declaration from the child's side to judge
|
|
7
|
+
* a write. Only what no foreign key holds reaches here — a key answers at the rows, inside the
|
|
8
|
+
* delete's own transaction, and nothing in this process would see it happen.
|
|
9
|
+
*
|
|
10
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
11
|
+
*/
|
|
12
|
+
export interface Dependent {
|
|
13
|
+
/** The entity whose rows name the target — `post`. */
|
|
14
|
+
entity: string;
|
|
15
|
+
/** The field carrying the key — `authorId`. */
|
|
16
|
+
field: string;
|
|
17
|
+
/** Unstated is `restrict`: what a foreign key does when nothing is said. */
|
|
18
|
+
onDelete: OnDelete;
|
|
19
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { RoutePolicy } from './RoutePolicy.js';
|
|
|
7
7
|
import { RouteRegistry } from './RouteRegistry.js';
|
|
8
8
|
import { routeNotFound, servedOperations } from './routeNotFound.js';
|
|
9
9
|
import type { InFlight } from './InFlight.js';
|
|
10
|
+
import type { Journal } from './Journal.js';
|
|
10
11
|
|
|
11
12
|
/** Resolves and executes every call through the same transverse lifecycle. */
|
|
12
13
|
export class Dispatcher implements DispatchPort {
|
|
@@ -15,22 +16,57 @@ export class Dispatcher implements DispatchPort {
|
|
|
15
16
|
private readonly inFlight: InFlight,
|
|
16
17
|
private readonly lifecycle = new DispatchLifecycle(),
|
|
17
18
|
private readonly policy?: RoutePolicy,
|
|
19
|
+
private readonly journalOf?: () => Journal | undefined,
|
|
18
20
|
) {}
|
|
19
21
|
|
|
22
|
+
/** The route that answers this address, or the refusal naming what is served instead. */
|
|
23
|
+
private async routeFor(call: Call): Promise<Route> {
|
|
24
|
+
const known = this.routes.find(call.address);
|
|
25
|
+
const resolved = known ?? await this.routes.resolve(call.address);
|
|
26
|
+
const route = resolved && (!this.policy || this.policy.accepts(resolved)) ? resolved : undefined;
|
|
27
|
+
if (!route) {
|
|
28
|
+
throw this.policy?.notFound?.(call, this.routes.routes())
|
|
29
|
+
?? routeNotFound(call, servedOperations(call, this.routes.routes(), this.policy));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return route;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A kept call publishes no dispatch event and enters no flight: it has not been answered,
|
|
37
|
+
* and counting it here would report one call spanning the days until its hour.
|
|
38
|
+
*
|
|
39
|
+
* Its address is resolved all the same. What is kept is made hours later, by then against
|
|
40
|
+
* whatever the app still serves — so an address nothing answers is refused to the caller who
|
|
41
|
+
* can still fix it, rather than to a beat nobody is watching.
|
|
42
|
+
*/
|
|
43
|
+
private async keep(call: Call, runAt: number): Promise<undefined> {
|
|
44
|
+
await this.routeFor(call);
|
|
45
|
+
|
|
46
|
+
const journal = this.journalOf?.();
|
|
47
|
+
if (!journal) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`${call.address.toString()} asks to run at ${new Date(runAt).toISOString()}, and nothing keeps it.`
|
|
50
|
+
+ ' Install a package answering Journal — @fougere/workflow.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await journal.keep(call, runAt);
|
|
55
|
+
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
async dispatch(call: Call): Promise<unknown> {
|
|
60
|
+
const { runAt } = call.invocation;
|
|
61
|
+
if (runAt !== undefined) return this.keep(call, runAt);
|
|
62
|
+
|
|
21
63
|
let route: Route | undefined;
|
|
22
64
|
let release: (() => void) | undefined;
|
|
23
65
|
this.lifecycle.publish(DispatchEvent.received(call));
|
|
24
66
|
|
|
25
67
|
try {
|
|
26
68
|
release = this.inFlight.enter(call.address.entity, call.address.operation);
|
|
27
|
-
|
|
28
|
-
const resolved = known ?? await this.routes.resolve(call.address);
|
|
29
|
-
route = resolved && (!this.policy || this.policy.accepts(resolved)) ? resolved : undefined;
|
|
30
|
-
if (!route) {
|
|
31
|
-
throw this.policy?.notFound?.(call, this.routes.routes())
|
|
32
|
-
?? routeNotFound(call, servedOperations(call, this.routes.routes(), this.policy));
|
|
33
|
-
}
|
|
69
|
+
route = await this.routeFor(call);
|
|
34
70
|
|
|
35
71
|
this.lifecycle.publish(DispatchEvent.resolved(call, route.kind));
|
|
36
72
|
const result = await route.execute(call);
|
|
@@ -40,6 +40,11 @@ export class HandlerFacade {
|
|
|
40
40
|
/** A computed field's parameters and the collectors in scope are both boot-time facts. */
|
|
41
41
|
private readonly presenterPlans: Map<string, BindingPlan>;
|
|
42
42
|
|
|
43
|
+
private collectorResolver = (typeName: string): CollectorResolver | undefined => {
|
|
44
|
+
try { return this.scope.resolve(collectorKeyOf(typeName)) as CollectorResolver; }
|
|
45
|
+
catch { return undefined; }
|
|
46
|
+
};
|
|
47
|
+
|
|
43
48
|
constructor(
|
|
44
49
|
private readonly handler: HandlerEntry,
|
|
45
50
|
private readonly scope: Container,
|
|
@@ -93,20 +98,30 @@ export class HandlerFacade {
|
|
|
93
98
|
invocation,
|
|
94
99
|
};
|
|
95
100
|
|
|
96
|
-
return runMiddlewares(this.facade.middlewares(), context,
|
|
97
|
-
|
|
98
|
-
context.invocation = validated;
|
|
101
|
+
return runMiddlewares(this.facade.middlewares(), context, () => this.answer(op, contract, context, invocation));
|
|
102
|
+
}
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
/**
|
|
105
|
+
* What the handler answers, once the middlewares let the call through: the input judged, the
|
|
106
|
+
* arguments bound, the row projected onto the view this audience sees.
|
|
107
|
+
*/
|
|
108
|
+
private async answer(
|
|
109
|
+
op: string,
|
|
110
|
+
contract: OperationContract,
|
|
111
|
+
context: OperationContext,
|
|
112
|
+
invocation: Invocation,
|
|
113
|
+
): Promise<unknown> {
|
|
114
|
+
const validated = validateInput(contract.input, invocation, this.handler.address, op);
|
|
115
|
+
context.invocation = validated;
|
|
106
116
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
const args = contract.binding ? await this.arguments.resolve(contract.binding, validated) : [];
|
|
118
|
+
const { instance, method } = this.resolveImplementation(op);
|
|
119
|
+
const view = this.viewOf(op);
|
|
120
|
+
const output = view.project(await instance[method](...args));
|
|
121
|
+
|
|
122
|
+
const { presenter } = this.facade;
|
|
123
|
+
|
|
124
|
+
return view.closed || !presenter ? output : this.present(op, presenter, output, validated);
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
/** The computed fields a presenter adds, over the page the façade just projected. */
|
|
@@ -168,11 +183,6 @@ export class HandlerFacade {
|
|
|
168
183
|
return resolved;
|
|
169
184
|
}
|
|
170
185
|
|
|
171
|
-
private collectorResolver = (typeName: string): CollectorResolver | undefined => {
|
|
172
|
-
try { return this.scope.resolve(collectorKeyOf(typeName)) as CollectorResolver; }
|
|
173
|
-
catch { return undefined; }
|
|
174
|
-
};
|
|
175
|
-
|
|
176
186
|
/** The handler itself, resolved on first call — never at boot. */
|
|
177
187
|
private resolveHandler(): any {
|
|
178
188
|
if (!this.instance) this.instance = this.scope.resolve(this.handlerKey);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Call } from '../wire/Call.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What writes a release down while it happens, when a package provides one.
|
|
5
|
+
*
|
|
6
|
+
* Core does the same hops in the same order either way — children before their parent, so an
|
|
7
|
+
* interruption leaves fewer children and never an orphan. What a journal adds is that somebody
|
|
8
|
+
* can finish what a stopped process started: the row says a release began, and a sweep redoes
|
|
9
|
+
* it. Redoing is safe because every hop already is — a row taken out twice is taken out once.
|
|
10
|
+
*
|
|
11
|
+
* What writes a release down writes none of its own: the boot leaves out the entities of the
|
|
12
|
+
* fronds an extension BROUGHT, so keeping the row that says a release began does not begin one.
|
|
13
|
+
* Read from what was brought rather than written down, the way `CARRIES_LINE` is.
|
|
14
|
+
*
|
|
15
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
16
|
+
*/
|
|
17
|
+
export interface Journal {
|
|
18
|
+
/** Hold this call until `runAt`, so a sweep dispatches it then and nobody waits for it now. */
|
|
19
|
+
keep(call: Call, runAt: number): Promise<void>;
|
|
20
|
+
/** Take this release on, or say another process is already driving it. */
|
|
21
|
+
open(entity: string, key: string): Promise<'taken' | 'busy'>;
|
|
22
|
+
close(entity: string, key: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The container key a package registers its journal under. */
|
|
26
|
+
export const JOURNAL = 'Journal';
|