@fougere/workflow 0.11.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/LICENSE +21 -0
- package/dist/Later.d.ts +24 -0
- package/dist/Later.d.ts.map +1 -0
- package/dist/Later.js +23 -0
- package/dist/Later.js.map +1 -0
- package/dist/LaterRepository.d.ts +29 -0
- package/dist/LaterRepository.d.ts.map +1 -0
- package/dist/LaterRepository.js +57 -0
- package/dist/LaterRepository.js.map +1 -0
- package/dist/Run.d.ts +25 -0
- package/dist/Run.d.ts.map +1 -0
- package/dist/Run.js +24 -0
- package/dist/Run.js.map +1 -0
- package/dist/RunHandler.d.ts +18 -0
- package/dist/RunHandler.d.ts.map +1 -0
- package/dist/RunHandler.js +30 -0
- package/dist/RunHandler.js.map +1 -0
- package/dist/RunJournal.d.ts +21 -0
- package/dist/RunJournal.d.ts.map +1 -0
- package/dist/RunJournal.js +28 -0
- package/dist/RunJournal.js.map +1 -0
- package/dist/RunRepository.d.ts +21 -0
- package/dist/RunRepository.d.ts.map +1 -0
- package/dist/RunRepository.js +41 -0
- package/dist/RunRepository.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
- package/src/Later.ts +22 -0
- package/src/LaterRepository.ts +65 -0
- package/src/Run.ts +23 -0
- package/src/RunHandler.ts +28 -0
- package/src/RunJournal.ts +30 -0
- package/src/RunRepository.ts +47 -0
- package/src/index.ts +84 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fougere contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/Later.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
declare const Later_base: import("@fougere/schema").SchemaConstructor<{
|
|
2
|
+
id: import("@fougere/schema").Field<string>;
|
|
3
|
+
/** The address, flat, so a due row is found without reading what it carries. */
|
|
4
|
+
entity: import("@fougere/schema").Field<string>;
|
|
5
|
+
operation: import("@fougere/schema").Field<string>;
|
|
6
|
+
/** What the caller supplied — params, query, input, identity. `runAt` is dropped on the way out. */
|
|
7
|
+
invocation: import("@fougere/schema").Field<unknown>;
|
|
8
|
+
runAt: import("@fougere/schema").Field<number>;
|
|
9
|
+
takenUntil: import("@fougere/schema").Field<number | null>;
|
|
10
|
+
}>;
|
|
11
|
+
/**
|
|
12
|
+
* A call that has not happened yet, and who is making it happen.
|
|
13
|
+
*
|
|
14
|
+
* Distinct from `Run`, which says a release BEGAN and can be redone: this one never began, so
|
|
15
|
+
* it goes out exactly once, and a taker holds it for a bounded time rather than locking it —
|
|
16
|
+
* the same answer `RunRepository` gives, for the same reason a process cannot tell a dead peer
|
|
17
|
+
* from a slow one.
|
|
18
|
+
*
|
|
19
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
20
|
+
*/
|
|
21
|
+
export default class Later extends Later_base {
|
|
22
|
+
}
|
|
23
|
+
export {};
|
|
24
|
+
//# sourceMappingURL=Later.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Later.d.ts","sourceRoot":"","sources":["../src/Later.ts"],"names":[],"mappings":";;IAcE,gFAAgF;;;IAGhF,oGAAoG;;;;;AAftG;;;;;;;;;GASG;AACH,MAAM,CAAC,OAAO,OAAO,KAAM,SAAQ,UASjC;CAAG"}
|
package/dist/Later.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { entity, json, number, optional, primary, text } from '@fougere/schema';
|
|
2
|
+
/**
|
|
3
|
+
* A call that has not happened yet, and who is making it happen.
|
|
4
|
+
*
|
|
5
|
+
* Distinct from `Run`, which says a release BEGAN and can be redone: this one never began, so
|
|
6
|
+
* it goes out exactly once, and a taker holds it for a bounded time rather than locking it —
|
|
7
|
+
* the same answer `RunRepository` gives, for the same reason a process cannot tell a dead peer
|
|
8
|
+
* from a slow one.
|
|
9
|
+
*
|
|
10
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
11
|
+
*/
|
|
12
|
+
export default class Later extends entity({
|
|
13
|
+
id: primary(text()),
|
|
14
|
+
/** The address, flat, so a due row is found without reading what it carries. */
|
|
15
|
+
entity: text(),
|
|
16
|
+
operation: text(),
|
|
17
|
+
/** What the caller supplied — params, query, input, identity. `runAt` is dropped on the way out. */
|
|
18
|
+
invocation: json(),
|
|
19
|
+
runAt: number({ integer: true }),
|
|
20
|
+
takenUntil: optional(number({ integer: true })),
|
|
21
|
+
}) {
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=Later.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Later.js","sourceRoot":"","sources":["../src/Later.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAEhF;;;;;;;;;GASG;AACH,MAAM,CAAC,OAAO,OAAO,KAAM,SAAQ,MAAM,CAAC;IACxC,EAAE,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC;IACnB,gFAAgF;IAChF,MAAM,EAAE,IAAI,EAAE;IACd,SAAS,EAAE,IAAI,EAAE;IACjB,oGAAoG;IACpG,UAAU,EAAE,IAAI,EAAE;IAClB,KAAK,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;IAChC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;CAChD,CAAC;CAAG"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Call, type Storage } from '@fougere/core';
|
|
2
|
+
import Later from './Later.js';
|
|
3
|
+
declare const LaterRepository_base: import("@fougere/core").RepositoryConstructor<Later>;
|
|
4
|
+
/**
|
|
5
|
+
* The calls waiting for their hour, and the take that says one process is making them.
|
|
6
|
+
*
|
|
7
|
+
* The take is bounded like `RunRepository`'s lease and for the same reason, but it guards the
|
|
8
|
+
* opposite case: a release may be redone, a call may not, so what is bounded here is how long
|
|
9
|
+
* a taker is believed rather than how long a driver is assumed alive.
|
|
10
|
+
*/
|
|
11
|
+
export default class LaterRepository extends LaterRepository_base {
|
|
12
|
+
private now;
|
|
13
|
+
constructor(storage: Storage<Later>, now?: () => number);
|
|
14
|
+
keep(call: Call, runAt: number): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* The calls whose hour has passed and whom nobody is making right now.
|
|
17
|
+
*
|
|
18
|
+
* Read whole and filtered here, which bounds what this package holds: a source that answers
|
|
19
|
+
* `where` on two numbers would answer it at the rows, and that is the next thing to ask for.
|
|
20
|
+
*/
|
|
21
|
+
due(): Promise<Later[]>;
|
|
22
|
+
/** Hold this one, or say another process already has it — the read and the write are two. */
|
|
23
|
+
take(id: string): Promise<'taken' | 'busy'>;
|
|
24
|
+
done(id: string): Promise<void>;
|
|
25
|
+
/** Rebuilds what was kept — the address flat, the invocation whole. */
|
|
26
|
+
callOf(row: Later): Call;
|
|
27
|
+
}
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=LaterRepository.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LaterRepository.d.ts","sourceRoot":"","sources":["../src/LaterRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAA4B,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAC7E,OAAO,KAAK,MAAM,YAAY,CAAC;;AAK/B;;;;;;GAMG;AACH,MAAM,CAAC,OAAO,OAAO,eAAgB,SAAQ,oBAAiB;IACvB,OAAO,CAAC,GAAG;IAAhD,YAAY,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,EAAU,GAAG,GAAE,MAAM,MAAiB,EAExE;IAEK,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CASnD;IAED;;;;;OAKG;IACG,GAAG,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC,CAK5B;IAED,6FAA6F;IACvF,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,MAAM,CAAC,CAQhD;IAEK,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEpC;IAED,uEAAuE;IACvE,MAAM,CAAC,GAAG,EAAE,KAAK,GAAG,IAAI,CAKvB;CACF"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Call, Repository, RouteAddress } from '@fougere/core';
|
|
2
|
+
import Later from './Later.js';
|
|
3
|
+
/** How long a taker holds a due call before anyone may take it over. */
|
|
4
|
+
const TAKE_MS = 30_000;
|
|
5
|
+
/**
|
|
6
|
+
* The calls waiting for their hour, and the take that says one process is making them.
|
|
7
|
+
*
|
|
8
|
+
* The take is bounded like `RunRepository`'s lease and for the same reason, but it guards the
|
|
9
|
+
* opposite case: a release may be redone, a call may not, so what is bounded here is how long
|
|
10
|
+
* a taker is believed rather than how long a driver is assumed alive.
|
|
11
|
+
*/
|
|
12
|
+
export default class LaterRepository extends Repository(Later) {
|
|
13
|
+
now;
|
|
14
|
+
constructor(storage, now = Date.now) {
|
|
15
|
+
super(storage);
|
|
16
|
+
this.now = now;
|
|
17
|
+
}
|
|
18
|
+
async keep(call, runAt) {
|
|
19
|
+
const { runAt: _dropped, ...invocation } = call.invocation;
|
|
20
|
+
await this.create({
|
|
21
|
+
id: crypto.randomUUID(),
|
|
22
|
+
entity: call.address.entity,
|
|
23
|
+
operation: call.address.operation,
|
|
24
|
+
invocation,
|
|
25
|
+
runAt,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The calls whose hour has passed and whom nobody is making right now.
|
|
30
|
+
*
|
|
31
|
+
* Read whole and filtered here, which bounds what this package holds: a source that answers
|
|
32
|
+
* `where` on two numbers would answer it at the rows, and that is the next thing to ask for.
|
|
33
|
+
*/
|
|
34
|
+
async due() {
|
|
35
|
+
const rows = await this.list();
|
|
36
|
+
const now = this.now();
|
|
37
|
+
return rows.filter((row) => row.runAt <= now && (row.takenUntil ?? 0) <= now);
|
|
38
|
+
}
|
|
39
|
+
/** Hold this one, or say another process already has it — the read and the write are two. */
|
|
40
|
+
async take(id) {
|
|
41
|
+
const held = await this.findById(id);
|
|
42
|
+
if (!held)
|
|
43
|
+
return 'busy';
|
|
44
|
+
if ((held.takenUntil ?? 0) > this.now())
|
|
45
|
+
return 'busy';
|
|
46
|
+
await this.update(id, { takenUntil: this.now() + TAKE_MS });
|
|
47
|
+
return 'taken';
|
|
48
|
+
}
|
|
49
|
+
async done(id) {
|
|
50
|
+
await this.delete(id);
|
|
51
|
+
}
|
|
52
|
+
/** Rebuilds what was kept — the address flat, the invocation whole. */
|
|
53
|
+
callOf(row) {
|
|
54
|
+
return new Call(new RouteAddress({ entity: row.entity, operation: row.operation }), row.invocation);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=LaterRepository.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LaterRepository.js","sourceRoot":"","sources":["../src/LaterRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,YAAY,EAAgB,MAAM,eAAe,CAAC;AAC7E,OAAO,KAAK,MAAM,YAAY,CAAC;AAE/B,wEAAwE;AACxE,MAAM,OAAO,GAAG,MAAM,CAAC;AAEvB;;;;;;GAMG;AACH,MAAM,CAAC,OAAO,OAAO,eAAgB,SAAQ,UAAU,CAAC,KAAK,CAAC;IACf,GAAG;IAAhD,YAAY,OAAuB,EAAU,GAAG,GAAiB,IAAI,CAAC,GAAG;QACvE,KAAK,CAAC,OAAO,CAAC,CAAC;mBAD4B,GAAG;IAEhD,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,IAAU,EAAE,KAAa;QAClC,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,UAAU,EAAE,GAAG,IAAI,CAAC,UAAU,CAAC;QAC3D,MAAM,IAAI,CAAC,MAAM,CAAC;YAChB,EAAE,EAAE,MAAM,CAAC,UAAU,EAAE;YACvB,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM;YAC3B,SAAS,EAAE,IAAI,CAAC,OAAO,CAAC,SAAS;YACjC,UAAU;YACV,KAAK;SACN,CAAC,CAAC;IACL,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,GAAG;QACP,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;QAC/B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAEvB,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,IAAI,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC;IAChF,CAAC;IAED,6FAA6F;IAC7F,KAAK,CAAC,IAAI,CAAC,EAAU;QACnB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;QACrC,IAAI,CAAC,IAAI;YAAE,OAAO,MAAM,CAAC;QACzB,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE;YAAE,OAAO,MAAM,CAAC;QAEvD,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;QAE5D,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,EAAU;QACnB,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACxB,CAAC;IAED,uEAAuE;IACvE,MAAM,CAAC,GAAU;QACf,OAAO,IAAI,IAAI,CACb,IAAI,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,EAClE,GAAG,CAAC,UAAqC,CAC1C,CAAC;IACJ,CAAC;CACF"}
|
package/dist/Run.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
declare const Run_base: import("@fougere/schema").SchemaConstructor<{
|
|
2
|
+
/** `user:ada` — derived from what is released, so relaunching is the same run, not a second. */
|
|
3
|
+
id: import("@fougere/schema").Field<string>;
|
|
4
|
+
entity: import("@fougere/schema").Field<string>;
|
|
5
|
+
key: import("@fougere/schema").Field<string>;
|
|
6
|
+
status: import("@fougere/schema").Field<"done" | "running">;
|
|
7
|
+
/** Who is driving, and until when. An expired lease is what a sweep claims. */
|
|
8
|
+
leaseOwner: import("@fougere/schema").Field<string>;
|
|
9
|
+
leaseUntil: import("@fougere/schema").Field<number>;
|
|
10
|
+
finishedAt: import("@fougere/schema").Field<number | null>;
|
|
11
|
+
}>;
|
|
12
|
+
/**
|
|
13
|
+
* A release that is under way, and who is driving it.
|
|
14
|
+
*
|
|
15
|
+
* There is no plan here and no position, because a release needs neither: every hop it makes
|
|
16
|
+
* is already idempotent — a row taken out twice is taken out once, a field emptied twice is
|
|
17
|
+
* empty — so RESUMING is simply doing it again. What the row has to say is that somebody
|
|
18
|
+
* started and did not finish.
|
|
19
|
+
*
|
|
20
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
21
|
+
*/
|
|
22
|
+
export default class Run extends Run_base {
|
|
23
|
+
}
|
|
24
|
+
export {};
|
|
25
|
+
//# sourceMappingURL=Run.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Run.d.ts","sourceRoot":"","sources":["../src/Run.ts"],"names":[],"mappings":";IAaE,gGAAgG;;;;;IAKhG,+EAA+E;;;;;AAhBjF;;;;;;;;;GASG;AACH,MAAM,CAAC,OAAO,OAAO,GAAI,SAAQ,QAU/B;CAAG"}
|
package/dist/Run.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { entity, number, oneOf, optional, primary, text } from '@fougere/schema';
|
|
2
|
+
/**
|
|
3
|
+
* A release that is under way, and who is driving it.
|
|
4
|
+
*
|
|
5
|
+
* There is no plan here and no position, because a release needs neither: every hop it makes
|
|
6
|
+
* is already idempotent — a row taken out twice is taken out once, a field emptied twice is
|
|
7
|
+
* empty — so RESUMING is simply doing it again. What the row has to say is that somebody
|
|
8
|
+
* started and did not finish.
|
|
9
|
+
*
|
|
10
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
11
|
+
*/
|
|
12
|
+
export default class Run extends entity({
|
|
13
|
+
/** `user:ada` — derived from what is released, so relaunching is the same run, not a second. */
|
|
14
|
+
id: primary(text()),
|
|
15
|
+
entity: text(),
|
|
16
|
+
key: text(),
|
|
17
|
+
status: oneOf('running', 'done', { default: 'running' }),
|
|
18
|
+
/** Who is driving, and until when. An expired lease is what a sweep claims. */
|
|
19
|
+
leaseOwner: text(),
|
|
20
|
+
leaseUntil: number({ integer: true }),
|
|
21
|
+
finishedAt: optional(number({ integer: true })),
|
|
22
|
+
}) {
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=Run.js.map
|
package/dist/Run.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Run.js","sourceRoot":"","sources":["../src/Run.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AAEjF;;;;;;;;;GASG;AACH,MAAM,CAAC,OAAO,OAAO,GAAI,SAAQ,MAAM,CAAC;IACtC,gGAAgG;IAChG,EAAE,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC;IACnB,MAAM,EAAE,IAAI,EAAE;IACd,GAAG,EAAE,IAAI,EAAE;IACX,MAAM,EAAE,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC;IACxD,+EAA+E;IAC/E,UAAU,EAAE,IAAI,EAAE;IAClB,UAAU,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;IACrC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;CAChD,CAAC;CAAG"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Releasing } from '@fougere/core';
|
|
2
|
+
import RunRepository from './RunRepository.js';
|
|
3
|
+
/** Finish what a stopped process started. */
|
|
4
|
+
export default class RunHandler {
|
|
5
|
+
private runs;
|
|
6
|
+
private releasing;
|
|
7
|
+
constructor(runs: RunRepository, releasing: Releasing);
|
|
8
|
+
/**
|
|
9
|
+
* Redo every release whose driver stopped saying it was alive.
|
|
10
|
+
*
|
|
11
|
+
* An operation, so the CLI, an HTTP route and a cron all reach it the same way — a durable
|
|
12
|
+
* engine ships one host adapter per platform to get this far.
|
|
13
|
+
*/
|
|
14
|
+
sweep(): Promise<{
|
|
15
|
+
resumed: string[];
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=RunHandler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RunHandler.d.ts","sourceRoot":"","sources":["../src/RunHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AACxD,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAG/C,6CAA6C;AAC7C,MAAM,CAAC,OAAO,OAAO,UAAU;IACjB,OAAO,CAAC,IAAI;IAAiB,OAAO,CAAC,SAAS;IAA1D,YAAoB,IAAI,EAAE,aAAa,EAAU,SAAS,EAAE,SAAS,EAAI;IAEzE;;;;;OAKG;IACG,KAAK,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAY5C;CACF"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { release } from '@fougere/core';
|
|
2
|
+
import { DRIVER } from './RunJournal.js';
|
|
3
|
+
/** Finish what a stopped process started. */
|
|
4
|
+
export default class RunHandler {
|
|
5
|
+
runs;
|
|
6
|
+
releasing;
|
|
7
|
+
constructor(runs, releasing) {
|
|
8
|
+
this.runs = runs;
|
|
9
|
+
this.releasing = releasing;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Redo every release whose driver stopped saying it was alive.
|
|
13
|
+
*
|
|
14
|
+
* An operation, so the CLI, an HTTP route and a cron all reach it the same way — a durable
|
|
15
|
+
* engine ships one host adapter per platform to get this far.
|
|
16
|
+
*/
|
|
17
|
+
async sweep() {
|
|
18
|
+
const resumed = [];
|
|
19
|
+
for (const run of await this.runs.abandoned()) {
|
|
20
|
+
if (await this.runs.open(run.entity, run.key, DRIVER) === 'busy')
|
|
21
|
+
continue;
|
|
22
|
+
// The row too: a release interrupted before it reached the row is not finished.
|
|
23
|
+
await release(run.entity, run.key, this.releasing, [], async () => this.releasing.rowsOf(run.entity)?.delete(run.key));
|
|
24
|
+
await this.runs.close(run.entity, run.key);
|
|
25
|
+
resumed.push(run.id);
|
|
26
|
+
}
|
|
27
|
+
return { resumed };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=RunHandler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RunHandler.js","sourceRoot":"","sources":["../src/RunHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAkB,MAAM,eAAe,CAAC;AAExD,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAEzC,6CAA6C;AAC7C,MAAM,CAAC,OAAO,OAAO,UAAU;IACT,IAAI;IAAyB,SAAS;IAA1D,YAAoB,IAAmB,EAAU,SAAoB;oBAAjD,IAAI;yBAAyB,SAAS;IAAc,CAAC;IAEzE;;;;;OAKG;IACH,KAAK,CAAC,KAAK;QACT,MAAM,OAAO,GAAa,EAAE,CAAC;QAC7B,KAAK,MAAM,GAAG,IAAI,MAAM,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,EAAE,CAAC;YAC9C,IAAI,MAAM,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,KAAK,MAAM;gBAAE,SAAS;YAC3E,gFAAgF;YAChF,MAAM,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,EAAE,EAAE,EACnD,KAAK,IAAI,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;YAClE,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;YAC3C,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACvB,CAAC;QAED,OAAO,EAAE,OAAO,EAAE,CAAC;IACrB,CAAC;CACF"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { JOURNAL, type Call, type Journal } from '@fougere/core';
|
|
2
|
+
import LaterRepository from './LaterRepository.js';
|
|
3
|
+
import RunRepository from './RunRepository.js';
|
|
4
|
+
export { JOURNAL };
|
|
5
|
+
/**
|
|
6
|
+
* The journal core asks for, answered by a row.
|
|
7
|
+
*
|
|
8
|
+
* Registered under core's own key, which is how an optional package answers a reading core
|
|
9
|
+
* declares — the same shape a source takes when it says what it keeps at the rows.
|
|
10
|
+
*/
|
|
11
|
+
export default class RunJournal implements Journal {
|
|
12
|
+
private runs;
|
|
13
|
+
private later;
|
|
14
|
+
constructor(runs: RunRepository, later: LaterRepository);
|
|
15
|
+
keep(call: Call, runAt: number): Promise<void>;
|
|
16
|
+
open(entity: string, key: string): Promise<'taken' | 'busy'>;
|
|
17
|
+
close(entity: string, key: string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/** What this process calls itself while it drives a run — enough to tell it from another. */
|
|
20
|
+
export declare const DRIVER: string;
|
|
21
|
+
//# sourceMappingURL=RunJournal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RunJournal.d.ts","sourceRoot":"","sources":["../src/RunJournal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,KAAK,IAAI,EAAE,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AACjE,OAAO,eAAe,MAAM,sBAAsB,CAAC;AACnD,OAAO,aAAa,MAAM,oBAAoB,CAAC;AAE/C,OAAO,EAAE,OAAO,EAAE,CAAC;AAEnB;;;;;GAKG;AACH,MAAM,CAAC,OAAO,OAAO,UAAW,YAAW,OAAO;IACpC,OAAO,CAAC,IAAI;IAAiB,OAAO,CAAC,KAAK;IAAtD,YAAoB,IAAI,EAAE,aAAa,EAAU,KAAK,EAAE,eAAe,EAAI;IAE3E,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7C;IAED,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,MAAM,CAAC,CAE3D;IAED,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEhD;CACF;AAED,6FAA6F;AAC7F,eAAO,MAAM,MAAM,QAAmB,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { JOURNAL } from '@fougere/core';
|
|
2
|
+
export { JOURNAL };
|
|
3
|
+
/**
|
|
4
|
+
* The journal core asks for, answered by a row.
|
|
5
|
+
*
|
|
6
|
+
* Registered under core's own key, which is how an optional package answers a reading core
|
|
7
|
+
* declares — the same shape a source takes when it says what it keeps at the rows.
|
|
8
|
+
*/
|
|
9
|
+
export default class RunJournal {
|
|
10
|
+
runs;
|
|
11
|
+
later;
|
|
12
|
+
constructor(runs, later) {
|
|
13
|
+
this.runs = runs;
|
|
14
|
+
this.later = later;
|
|
15
|
+
}
|
|
16
|
+
keep(call, runAt) {
|
|
17
|
+
return this.later.keep(call, runAt);
|
|
18
|
+
}
|
|
19
|
+
open(entity, key) {
|
|
20
|
+
return this.runs.open(entity, key, DRIVER);
|
|
21
|
+
}
|
|
22
|
+
close(entity, key) {
|
|
23
|
+
return this.runs.close(entity, key);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** What this process calls itself while it drives a run — enough to tell it from another. */
|
|
27
|
+
export const DRIVER = `${process.pid}`;
|
|
28
|
+
//# sourceMappingURL=RunJournal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RunJournal.js","sourceRoot":"","sources":["../src/RunJournal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAA2B,MAAM,eAAe,CAAC;AAIjE,OAAO,EAAE,OAAO,EAAE,CAAC;AAEnB;;;;;GAKG;AACH,MAAM,CAAC,OAAO,OAAO,UAAU;IACT,IAAI;IAAyB,KAAK;IAAtD,YAAoB,IAAmB,EAAU,KAAsB;oBAAnD,IAAI;qBAAyB,KAAK;IAAoB,CAAC;IAE3E,IAAI,CAAC,IAAU,EAAE,KAAa;QAC5B,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IACtC,CAAC;IAED,IAAI,CAAC,MAAc,EAAE,GAAW;QAC9B,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC;IAC7C,CAAC;IAED,KAAK,CAAC,MAAc,EAAE,GAAW;QAC/B,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACtC,CAAC;CACF;AAED,6FAA6F;AAC7F,MAAM,CAAC,MAAM,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Storage } from '@fougere/core';
|
|
2
|
+
import Run from './Run.js';
|
|
3
|
+
declare const RunRepository_base: import("@fougere/core").RepositoryConstructor<Run>;
|
|
4
|
+
/**
|
|
5
|
+
* The runs, and the lease that says one process is driving.
|
|
6
|
+
*
|
|
7
|
+
* A lease is time-bounded on purpose: no process can tell whether another is dead or slow, so
|
|
8
|
+
* the question is never asked. What is bounded instead is how long an answer is assumed, and
|
|
9
|
+
* two drivers at once stay harmless because every hop can be done twice.
|
|
10
|
+
*/
|
|
11
|
+
export default class RunRepository extends RunRepository_base {
|
|
12
|
+
private now;
|
|
13
|
+
constructor(storage: Storage<Run>, now?: () => number);
|
|
14
|
+
/** Take this release on, or say it is already someone's — the same row either way. */
|
|
15
|
+
open(entity: string, key: string, owner: string): Promise<'taken' | 'busy'>;
|
|
16
|
+
close(entity: string, key: string): Promise<void>;
|
|
17
|
+
/** Runs whose driver stopped saying it was alive — what a sweep has to finish. */
|
|
18
|
+
abandoned(): Promise<Run[]>;
|
|
19
|
+
}
|
|
20
|
+
export {};
|
|
21
|
+
//# sourceMappingURL=RunRepository.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RunRepository.d.ts","sourceRoot":"","sources":["../src/RunRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,GAAG,MAAM,UAAU,CAAC;;AAK3B;;;;;;GAMG;AACH,MAAM,CAAC,OAAO,OAAO,aAAc,SAAQ,kBAAe;IACrB,OAAO,CAAC,GAAG;IAA9C,YAAY,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,EAAU,GAAG,GAAE,MAAM,MAAiB,EAEtE;IAED,sFAAsF;IAChF,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,MAAM,CAAC,CAehF;IAEK,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtD;IAED,kFAAkF;IAC5E,SAAS,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC,CAIhC;CACF"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Repository } from '@fougere/core';
|
|
2
|
+
import Run from './Run.js';
|
|
3
|
+
/** How long a driver holds a run before anyone may take it over. */
|
|
4
|
+
const LEASE_MS = 30_000;
|
|
5
|
+
/**
|
|
6
|
+
* The runs, and the lease that says one process is driving.
|
|
7
|
+
*
|
|
8
|
+
* A lease is time-bounded on purpose: no process can tell whether another is dead or slow, so
|
|
9
|
+
* the question is never asked. What is bounded instead is how long an answer is assumed, and
|
|
10
|
+
* two drivers at once stay harmless because every hop can be done twice.
|
|
11
|
+
*/
|
|
12
|
+
export default class RunRepository extends Repository(Run) {
|
|
13
|
+
now;
|
|
14
|
+
constructor(storage, now = Date.now) {
|
|
15
|
+
super(storage);
|
|
16
|
+
this.now = now;
|
|
17
|
+
}
|
|
18
|
+
/** Take this release on, or say it is already someone's — the same row either way. */
|
|
19
|
+
async open(entity, key, owner) {
|
|
20
|
+
const id = `${entity}:${String(key)}`;
|
|
21
|
+
const held = await this.findById(id);
|
|
22
|
+
const until = this.now() + LEASE_MS;
|
|
23
|
+
if (!held) {
|
|
24
|
+
await this.create({ id, entity, key: String(key), status: 'running', leaseOwner: owner, leaseUntil: until });
|
|
25
|
+
return 'taken';
|
|
26
|
+
}
|
|
27
|
+
if (held.status === 'running' && held.leaseUntil > this.now() && held.leaseOwner !== owner)
|
|
28
|
+
return 'busy';
|
|
29
|
+
await this.update(id, { status: 'running', leaseOwner: owner, leaseUntil: until, finishedAt: null });
|
|
30
|
+
return 'taken';
|
|
31
|
+
}
|
|
32
|
+
async close(entity, key) {
|
|
33
|
+
await this.update(`${entity}:${String(key)}`, { status: 'done', finishedAt: this.now() });
|
|
34
|
+
}
|
|
35
|
+
/** Runs whose driver stopped saying it was alive — what a sweep has to finish. */
|
|
36
|
+
async abandoned() {
|
|
37
|
+
const running = await this.findAllBy({ status: 'running' });
|
|
38
|
+
return running.filter((run) => run.leaseUntil <= this.now());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=RunRepository.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RunRepository.js","sourceRoot":"","sources":["../src/RunRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAgB,MAAM,eAAe,CAAC;AACzD,OAAO,GAAG,MAAM,UAAU,CAAC;AAE3B,oEAAoE;AACpE,MAAM,QAAQ,GAAG,MAAM,CAAC;AAExB;;;;;;GAMG;AACH,MAAM,CAAC,OAAO,OAAO,aAAc,SAAQ,UAAU,CAAC,GAAG,CAAC;IACb,GAAG;IAA9C,YAAY,OAAqB,EAAU,GAAG,GAAiB,IAAI,CAAC,GAAG;QACrE,KAAK,CAAC,OAAO,CAAC,CAAC;mBAD0B,GAAG;IAE9C,CAAC;IAED,sFAAsF;IACtF,KAAK,CAAC,IAAI,CAAC,MAAc,EAAE,GAAW,EAAE,KAAa;QACnD,MAAM,EAAE,GAAG,GAAG,MAAM,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;QACrC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,QAAQ,CAAC;QAEpC,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAC;YAE7G,OAAO,OAAO,CAAC;QACjB,CAAC;QACD,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS,IAAI,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,IAAI,CAAC,UAAU,KAAK,KAAK;YAAE,OAAO,MAAM,CAAC;QAE1G,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;QAErG,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,MAAc,EAAE,GAAW;QACrC,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,MAAM,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IAC5F,CAAC;IAED,kFAAkF;IAClF,KAAK,CAAC,SAAS;QACb,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC;QAE5D,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,UAAU,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC/D,CAAC;CACF"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { releasing, type Extension } from '@fougere/core';
|
|
2
|
+
export { default as Later } from './Later.js';
|
|
3
|
+
export { default as LaterRepository } from './LaterRepository.js';
|
|
4
|
+
export { default as Run } from './Run.js';
|
|
5
|
+
export { default as RunRepository } from './RunRepository.js';
|
|
6
|
+
export { default as RunJournal } from './RunJournal.js';
|
|
7
|
+
export { default as RunHandler } from './RunHandler.js';
|
|
8
|
+
export interface WorkflowOptions {
|
|
9
|
+
/** Milliseconds between sweeps. `0` never sweeps — a test drives `sweep()` itself. */
|
|
10
|
+
sweepMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function workflow({ sweepMs }?: WorkflowOptions): Extension;
|
|
13
|
+
export { releasing };
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAwB,SAAS,EAA0B,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAQxG,OAAO,EAAE,OAAO,IAAI,KAAK,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,GAAG,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACxD,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAKxD,MAAM,WAAW,eAAe;IAC9B,sFAAsF;IACtF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,wBAAgB,QAAQ,CAAC,EAAE,OAAkB,EAAE,GAAE,eAAoB,GAAG,SAAS,CAiDhF;AAED,OAAO,EAAE,SAAS,EAAE,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Call, frond, JOURNAL, releasing, RouteAddress } from '@fougere/core';
|
|
2
|
+
import Later from './Later.js';
|
|
3
|
+
import LaterRepository from './LaterRepository.js';
|
|
4
|
+
import Run from './Run.js';
|
|
5
|
+
import RunRepository from './RunRepository.js';
|
|
6
|
+
import RunJournal from './RunJournal.js';
|
|
7
|
+
import RunHandler from './RunHandler.js';
|
|
8
|
+
export { default as Later } from './Later.js';
|
|
9
|
+
export { default as LaterRepository } from './LaterRepository.js';
|
|
10
|
+
export { default as Run } from './Run.js';
|
|
11
|
+
export { default as RunRepository } from './RunRepository.js';
|
|
12
|
+
export { default as RunJournal } from './RunJournal.js';
|
|
13
|
+
export { default as RunHandler } from './RunHandler.js';
|
|
14
|
+
/** How often a process looks for a run somebody stopped driving. */
|
|
15
|
+
const SWEEP_MS = 10_000;
|
|
16
|
+
export function workflow({ sweepMs = SWEEP_MS } = {}) {
|
|
17
|
+
let beat;
|
|
18
|
+
return {
|
|
19
|
+
name: 'workflow',
|
|
20
|
+
fronds: [frond('workflow', {
|
|
21
|
+
entities: [Run, Later],
|
|
22
|
+
providers: [
|
|
23
|
+
{ ctor: RunRepository, deps: ['RunStorage'] },
|
|
24
|
+
{ ctor: LaterRepository, deps: ['LaterStorage'] },
|
|
25
|
+
// Under core's own key, which is how an optional package answers a reading core declares.
|
|
26
|
+
{ ctor: RunJournal, deps: ['RunRepository', 'LaterRepository'], name: JOURNAL },
|
|
27
|
+
],
|
|
28
|
+
// Stated, because a published package is not scanned: nothing reads this handler's
|
|
29
|
+
// source at boot, so what it answers is written here.
|
|
30
|
+
handlers: [{
|
|
31
|
+
ctor: RunHandler,
|
|
32
|
+
deps: ['RunRepository', 'Releasing'],
|
|
33
|
+
operations: { sweep: { binding: [] } },
|
|
34
|
+
}],
|
|
35
|
+
// `sweep` leads with no verb core knows, and it is the word the subject uses — so the
|
|
36
|
+
// kind is stated rather than the operation renamed.
|
|
37
|
+
operationsOverrides: { sweep: { kind: 'command', binding: [] } },
|
|
38
|
+
})],
|
|
39
|
+
up: (app) => {
|
|
40
|
+
if (sweepMs <= 0)
|
|
41
|
+
return;
|
|
42
|
+
const scope = app.container.resolve('frond:workflow');
|
|
43
|
+
const later = scope.resolve('LaterRepository');
|
|
44
|
+
/**
|
|
45
|
+
* A kept call goes out from HERE and not from an operation, because making it is a
|
|
46
|
+
* dispatch: an op that dispatched the due ones would answer for calls it only started.
|
|
47
|
+
*/
|
|
48
|
+
const beatOnce = async () => {
|
|
49
|
+
await app.dispatch(new Call(new RouteAddress({ entity: 'run', operation: 'sweep' })))
|
|
50
|
+
.catch(() => undefined);
|
|
51
|
+
for (const row of await later.due()) {
|
|
52
|
+
if (await later.take(row.id) === 'busy')
|
|
53
|
+
continue;
|
|
54
|
+
// Taken and not yet done: a call that throws is left for the take to expire.
|
|
55
|
+
await app.dispatch(later.callOf(row)).then(() => later.done(row.id), () => undefined);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
beat = setInterval(() => { void beatOnce().catch(() => undefined); }, sweepMs);
|
|
59
|
+
beat.unref?.();
|
|
60
|
+
},
|
|
61
|
+
down: () => { if (beat)
|
|
62
|
+
clearInterval(beat); },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export { releasing };
|
|
66
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fougere/workflow",
|
|
3
|
+
"version": "0.11.0-alpha.0",
|
|
4
|
+
"description": "A release that goes to the end: the journal is three entities, the sweep is an operation, and a shared journal is one line of remotes:.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"fougere",
|
|
7
|
+
"typescript",
|
|
8
|
+
"durable",
|
|
9
|
+
"workflow"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/chok/fougere.git",
|
|
15
|
+
"directory": "packages/workflow"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "dist/index.js",
|
|
19
|
+
"types": "dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"src"
|
|
30
|
+
],
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@fougere/core": "^0.11.0-alpha.0",
|
|
33
|
+
"@fougere/schema": "^0.11.0-alpha.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"vitest": "^4.1.0",
|
|
37
|
+
"@fougere/core": "0.11.0-alpha.0",
|
|
38
|
+
"@fougere/adapter-sql": "0.11.0-alpha.0",
|
|
39
|
+
"@fougere/schema": "0.11.0-alpha.0",
|
|
40
|
+
"@fougere/defaults": "0.11.0-alpha.0",
|
|
41
|
+
"@fougere/container": "0.11.0-alpha.0"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "rm -rf dist && tsc",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"test:watch": "vitest",
|
|
50
|
+
"typecheck": "tsc --noEmit -p tsconfig.test.json"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/Later.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { entity, json, number, optional, primary, text } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A call that has not happened yet, and who is making it happen.
|
|
5
|
+
*
|
|
6
|
+
* Distinct from `Run`, which says a release BEGAN and can be redone: this one never began, so
|
|
7
|
+
* it goes out exactly once, and a taker holds it for a bounded time rather than locking it —
|
|
8
|
+
* the same answer `RunRepository` gives, for the same reason a process cannot tell a dead peer
|
|
9
|
+
* from a slow one.
|
|
10
|
+
*
|
|
11
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
12
|
+
*/
|
|
13
|
+
export default class Later extends entity({
|
|
14
|
+
id: primary(text()),
|
|
15
|
+
/** The address, flat, so a due row is found without reading what it carries. */
|
|
16
|
+
entity: text(),
|
|
17
|
+
operation: text(),
|
|
18
|
+
/** What the caller supplied — params, query, input, identity. `runAt` is dropped on the way out. */
|
|
19
|
+
invocation: json(),
|
|
20
|
+
runAt: number({ integer: true }),
|
|
21
|
+
takenUntil: optional(number({ integer: true })),
|
|
22
|
+
}) {}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Call, Repository, RouteAddress, type Storage } from '@fougere/core';
|
|
2
|
+
import Later from './Later.js';
|
|
3
|
+
|
|
4
|
+
/** How long a taker holds a due call before anyone may take it over. */
|
|
5
|
+
const TAKE_MS = 30_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The calls waiting for their hour, and the take that says one process is making them.
|
|
9
|
+
*
|
|
10
|
+
* The take is bounded like `RunRepository`'s lease and for the same reason, but it guards the
|
|
11
|
+
* opposite case: a release may be redone, a call may not, so what is bounded here is how long
|
|
12
|
+
* a taker is believed rather than how long a driver is assumed alive.
|
|
13
|
+
*/
|
|
14
|
+
export default class LaterRepository extends Repository(Later) {
|
|
15
|
+
constructor(storage: Storage<Later>, private now: () => number = Date.now) {
|
|
16
|
+
super(storage);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async keep(call: Call, runAt: number): Promise<void> {
|
|
20
|
+
const { runAt: _dropped, ...invocation } = call.invocation;
|
|
21
|
+
await this.create({
|
|
22
|
+
id: crypto.randomUUID(),
|
|
23
|
+
entity: call.address.entity,
|
|
24
|
+
operation: call.address.operation,
|
|
25
|
+
invocation,
|
|
26
|
+
runAt,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The calls whose hour has passed and whom nobody is making right now.
|
|
32
|
+
*
|
|
33
|
+
* Read whole and filtered here, which bounds what this package holds: a source that answers
|
|
34
|
+
* `where` on two numbers would answer it at the rows, and that is the next thing to ask for.
|
|
35
|
+
*/
|
|
36
|
+
async due(): Promise<Later[]> {
|
|
37
|
+
const rows = await this.list();
|
|
38
|
+
const now = this.now();
|
|
39
|
+
|
|
40
|
+
return rows.filter((row) => row.runAt <= now && (row.takenUntil ?? 0) <= now);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Hold this one, or say another process already has it — the read and the write are two. */
|
|
44
|
+
async take(id: string): Promise<'taken' | 'busy'> {
|
|
45
|
+
const held = await this.findById(id);
|
|
46
|
+
if (!held) return 'busy';
|
|
47
|
+
if ((held.takenUntil ?? 0) > this.now()) return 'busy';
|
|
48
|
+
|
|
49
|
+
await this.update(id, { takenUntil: this.now() + TAKE_MS });
|
|
50
|
+
|
|
51
|
+
return 'taken';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async done(id: string): Promise<void> {
|
|
55
|
+
await this.delete(id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Rebuilds what was kept — the address flat, the invocation whole. */
|
|
59
|
+
callOf(row: Later): Call {
|
|
60
|
+
return new Call(
|
|
61
|
+
new RouteAddress({ entity: row.entity, operation: row.operation }),
|
|
62
|
+
row.invocation as Record<string, unknown>,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/Run.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { entity, number, oneOf, optional, primary, text } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A release that is under way, and who is driving it.
|
|
5
|
+
*
|
|
6
|
+
* There is no plan here and no position, because a release needs neither: every hop it makes
|
|
7
|
+
* is already idempotent — a row taken out twice is taken out once, a field emptied twice is
|
|
8
|
+
* empty — so RESUMING is simply doing it again. What the row has to say is that somebody
|
|
9
|
+
* started and did not finish.
|
|
10
|
+
*
|
|
11
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
12
|
+
*/
|
|
13
|
+
export default class Run extends entity({
|
|
14
|
+
/** `user:ada` — derived from what is released, so relaunching is the same run, not a second. */
|
|
15
|
+
id: primary(text()),
|
|
16
|
+
entity: text(),
|
|
17
|
+
key: text(),
|
|
18
|
+
status: oneOf('running', 'done', { default: 'running' }),
|
|
19
|
+
/** Who is driving, and until when. An expired lease is what a sweep claims. */
|
|
20
|
+
leaseOwner: text(),
|
|
21
|
+
leaseUntil: number({ integer: true }),
|
|
22
|
+
finishedAt: optional(number({ integer: true })),
|
|
23
|
+
}) {}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { release, type Releasing } from '@fougere/core';
|
|
2
|
+
import RunRepository from './RunRepository.js';
|
|
3
|
+
import { DRIVER } from './RunJournal.js';
|
|
4
|
+
|
|
5
|
+
/** Finish what a stopped process started. */
|
|
6
|
+
export default class RunHandler {
|
|
7
|
+
constructor(private runs: RunRepository, private releasing: Releasing) {}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Redo every release whose driver stopped saying it was alive.
|
|
11
|
+
*
|
|
12
|
+
* An operation, so the CLI, an HTTP route and a cron all reach it the same way — a durable
|
|
13
|
+
* engine ships one host adapter per platform to get this far.
|
|
14
|
+
*/
|
|
15
|
+
async sweep(): Promise<{ resumed: string[] }> {
|
|
16
|
+
const resumed: string[] = [];
|
|
17
|
+
for (const run of await this.runs.abandoned()) {
|
|
18
|
+
if (await this.runs.open(run.entity, run.key, DRIVER) === 'busy') continue;
|
|
19
|
+
// The row too: a release interrupted before it reached the row is not finished.
|
|
20
|
+
await release(run.entity, run.key, this.releasing, [],
|
|
21
|
+
async () => this.releasing.rowsOf(run.entity)?.delete(run.key));
|
|
22
|
+
await this.runs.close(run.entity, run.key);
|
|
23
|
+
resumed.push(run.id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { resumed };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { JOURNAL, type Call, type Journal } from '@fougere/core';
|
|
2
|
+
import LaterRepository from './LaterRepository.js';
|
|
3
|
+
import RunRepository from './RunRepository.js';
|
|
4
|
+
|
|
5
|
+
export { JOURNAL };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The journal core asks for, answered by a row.
|
|
9
|
+
*
|
|
10
|
+
* Registered under core's own key, which is how an optional package answers a reading core
|
|
11
|
+
* declares — the same shape a source takes when it says what it keeps at the rows.
|
|
12
|
+
*/
|
|
13
|
+
export default class RunJournal implements Journal {
|
|
14
|
+
constructor(private runs: RunRepository, private later: LaterRepository) {}
|
|
15
|
+
|
|
16
|
+
keep(call: Call, runAt: number): Promise<void> {
|
|
17
|
+
return this.later.keep(call, runAt);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
open(entity: string, key: string): Promise<'taken' | 'busy'> {
|
|
21
|
+
return this.runs.open(entity, key, DRIVER);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
close(entity: string, key: string): Promise<void> {
|
|
25
|
+
return this.runs.close(entity, key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What this process calls itself while it drives a run — enough to tell it from another. */
|
|
30
|
+
export const DRIVER = `${process.pid}`;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Repository, type Storage } from '@fougere/core';
|
|
2
|
+
import Run from './Run.js';
|
|
3
|
+
|
|
4
|
+
/** How long a driver holds a run before anyone may take it over. */
|
|
5
|
+
const LEASE_MS = 30_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The runs, and the lease that says one process is driving.
|
|
9
|
+
*
|
|
10
|
+
* A lease is time-bounded on purpose: no process can tell whether another is dead or slow, so
|
|
11
|
+
* the question is never asked. What is bounded instead is how long an answer is assumed, and
|
|
12
|
+
* two drivers at once stay harmless because every hop can be done twice.
|
|
13
|
+
*/
|
|
14
|
+
export default class RunRepository extends Repository(Run) {
|
|
15
|
+
constructor(storage: Storage<Run>, private now: () => number = Date.now) {
|
|
16
|
+
super(storage);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Take this release on, or say it is already someone's — the same row either way. */
|
|
20
|
+
async open(entity: string, key: string, owner: string): Promise<'taken' | 'busy'> {
|
|
21
|
+
const id = `${entity}:${String(key)}`;
|
|
22
|
+
const held = await this.findById(id);
|
|
23
|
+
const until = this.now() + LEASE_MS;
|
|
24
|
+
|
|
25
|
+
if (!held) {
|
|
26
|
+
await this.create({ id, entity, key: String(key), status: 'running', leaseOwner: owner, leaseUntil: until });
|
|
27
|
+
|
|
28
|
+
return 'taken';
|
|
29
|
+
}
|
|
30
|
+
if (held.status === 'running' && held.leaseUntil > this.now() && held.leaseOwner !== owner) return 'busy';
|
|
31
|
+
|
|
32
|
+
await this.update(id, { status: 'running', leaseOwner: owner, leaseUntil: until, finishedAt: null });
|
|
33
|
+
|
|
34
|
+
return 'taken';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async close(entity: string, key: string): Promise<void> {
|
|
38
|
+
await this.update(`${entity}:${String(key)}`, { status: 'done', finishedAt: this.now() });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Runs whose driver stopped saying it was alive — what a sweep has to finish. */
|
|
42
|
+
async abandoned(): Promise<Run[]> {
|
|
43
|
+
const running = await this.findAllBy({ status: 'running' });
|
|
44
|
+
|
|
45
|
+
return running.filter((run) => run.leaseUntil <= this.now());
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A release that goes to the end — the journal is one entity, and the sweep is an operation.
|
|
3
|
+
*
|
|
4
|
+
* Stated and not scanned: `frond()` is what a published package hands over, so a consumer
|
|
5
|
+
* needs no TypeScript loader and no scan of its `node_modules`. The frond is BROUGHT by the
|
|
6
|
+
* extension, which puts it in every process that installs it — and putting it behind
|
|
7
|
+
* `remotes:` is then how two processes come to share one journal.
|
|
8
|
+
*/
|
|
9
|
+
import type { Container } from '@fougere/container';
|
|
10
|
+
import { Call, frond, JOURNAL, releasing, RouteAddress, type App, type Extension } from '@fougere/core';
|
|
11
|
+
import Later from './Later.js';
|
|
12
|
+
import LaterRepository from './LaterRepository.js';
|
|
13
|
+
import Run from './Run.js';
|
|
14
|
+
import RunRepository from './RunRepository.js';
|
|
15
|
+
import RunJournal from './RunJournal.js';
|
|
16
|
+
import RunHandler from './RunHandler.js';
|
|
17
|
+
|
|
18
|
+
export { default as Later } from './Later.js';
|
|
19
|
+
export { default as LaterRepository } from './LaterRepository.js';
|
|
20
|
+
export { default as Run } from './Run.js';
|
|
21
|
+
export { default as RunRepository } from './RunRepository.js';
|
|
22
|
+
export { default as RunJournal } from './RunJournal.js';
|
|
23
|
+
export { default as RunHandler } from './RunHandler.js';
|
|
24
|
+
|
|
25
|
+
/** How often a process looks for a run somebody stopped driving. */
|
|
26
|
+
const SWEEP_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
export interface WorkflowOptions {
|
|
29
|
+
/** Milliseconds between sweeps. `0` never sweeps — a test drives `sweep()` itself. */
|
|
30
|
+
sweepMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function workflow({ sweepMs = SWEEP_MS }: WorkflowOptions = {}): Extension {
|
|
34
|
+
let beat: ReturnType<typeof setInterval> | undefined;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
name: 'workflow',
|
|
38
|
+
fronds: [frond('workflow', {
|
|
39
|
+
entities: [Run, Later],
|
|
40
|
+
providers: [
|
|
41
|
+
{ ctor: RunRepository, deps: ['RunStorage'] },
|
|
42
|
+
{ ctor: LaterRepository, deps: ['LaterStorage'] },
|
|
43
|
+
// Under core's own key, which is how an optional package answers a reading core declares.
|
|
44
|
+
{ ctor: RunJournal, deps: ['RunRepository', 'LaterRepository'], name: JOURNAL },
|
|
45
|
+
],
|
|
46
|
+
// Stated, because a published package is not scanned: nothing reads this handler's
|
|
47
|
+
// source at boot, so what it answers is written here.
|
|
48
|
+
handlers: [{
|
|
49
|
+
ctor: RunHandler,
|
|
50
|
+
deps: ['RunRepository', 'Releasing'],
|
|
51
|
+
operations: { sweep: { binding: [] } },
|
|
52
|
+
}],
|
|
53
|
+
// `sweep` leads with no verb core knows, and it is the word the subject uses — so the
|
|
54
|
+
// kind is stated rather than the operation renamed.
|
|
55
|
+
operationsOverrides: { sweep: { kind: 'command', binding: [] } },
|
|
56
|
+
})],
|
|
57
|
+
up: (app: App) => {
|
|
58
|
+
if (sweepMs <= 0) return;
|
|
59
|
+
const scope = app.container.resolve<Container>('frond:workflow');
|
|
60
|
+
const later = scope.resolve<LaterRepository>('LaterRepository');
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A kept call goes out from HERE and not from an operation, because making it is a
|
|
64
|
+
* dispatch: an op that dispatched the due ones would answer for calls it only started.
|
|
65
|
+
*/
|
|
66
|
+
const beatOnce = async () => {
|
|
67
|
+
await app.dispatch(new Call(new RouteAddress({ entity: 'run', operation: 'sweep' })))
|
|
68
|
+
.catch(() => undefined);
|
|
69
|
+
|
|
70
|
+
for (const row of await later.due()) {
|
|
71
|
+
if (await later.take(row.id) === 'busy') continue;
|
|
72
|
+
// Taken and not yet done: a call that throws is left for the take to expire.
|
|
73
|
+
await app.dispatch(later.callOf(row)).then(() => later.done(row.id), () => undefined);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
beat = setInterval(() => { void beatOnce().catch(() => undefined); }, sweepMs);
|
|
78
|
+
beat.unref?.();
|
|
79
|
+
},
|
|
80
|
+
down: () => { if (beat) clearInterval(beat); },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { releasing };
|