gemi 0.56.0 → 0.58.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/app/index.js +1 -1
- package/dist/broadcasting/index.js +1 -1
- package/dist/bun/plugin.js +1 -1
- package/dist/bun/preload.js +1 -1
- package/dist/{chunk-jdj7k3r9.js → chunk-0a2xgcj3.js} +2 -2
- package/dist/{chunk-jdj7k3r9.js.map → chunk-0a2xgcj3.js.map} +1 -1
- package/dist/{chunk-3e88tyee.js → chunk-3337e5g0.js} +2 -2
- package/dist/{chunk-3e88tyee.js.map → chunk-3337e5g0.js.map} +1 -1
- package/dist/chunk-3aa287k7.js +6 -0
- package/dist/{chunk-cv9w5cmb.js.map → chunk-3aa287k7.js.map} +2 -2
- package/dist/{chunk-01am9k5v.js → chunk-3g5bjvdf.js} +2 -2
- package/dist/{chunk-01am9k5v.js.map → chunk-3g5bjvdf.js.map} +1 -1
- package/dist/{chunk-w9k9s4wh.js → chunk-3xadx444.js} +2 -2
- package/dist/{chunk-w9k9s4wh.js.map → chunk-3xadx444.js.map} +1 -1
- package/dist/{chunk-npg72mez.js → chunk-3zxwscmf.js} +2 -2
- package/dist/{chunk-kgmr9qxx.js.map → chunk-3zxwscmf.js.map} +1 -1
- package/dist/{chunk-64s1pzz1.js → chunk-437085pe.js} +2 -2
- package/dist/{chunk-64s1pzz1.js.map → chunk-437085pe.js.map} +1 -1
- package/dist/{chunk-eqrd31ye.js → chunk-4yt5x8s2.js} +2 -2
- package/dist/{chunk-eqrd31ye.js.map → chunk-4yt5x8s2.js.map} +1 -1
- package/dist/{chunk-9r0sb4zn.js → chunk-5athahgr.js} +2 -2
- package/dist/{chunk-9r0sb4zn.js.map → chunk-5athahgr.js.map} +1 -1
- package/dist/{chunk-g30q4n5y.js → chunk-5n2rvfh3.js} +2 -2
- package/dist/{chunk-g30q4n5y.js.map → chunk-5n2rvfh3.js.map} +1 -1
- package/dist/{chunk-1zfsgffv.js → chunk-6235kb30.js} +3 -3
- package/dist/{chunk-1zfsgffv.js.map → chunk-6235kb30.js.map} +1 -1
- package/dist/{chunk-javjeayw.js → chunk-7b0x860b.js} +2 -2
- package/dist/{chunk-javjeayw.js.map → chunk-7b0x860b.js.map} +1 -1
- package/dist/{chunk-4qwwy968.js → chunk-7ef5n8k2.js} +2 -2
- package/dist/{chunk-4qwwy968.js.map → chunk-7ef5n8k2.js.map} +1 -1
- package/dist/{chunk-q0waxxz5.js → chunk-7j6wbv12.js} +2 -2
- package/dist/{chunk-q0waxxz5.js.map → chunk-7j6wbv12.js.map} +1 -1
- package/dist/chunk-86jebsm4.js +9 -0
- package/dist/{chunk-kry5vwam.js.map → chunk-86jebsm4.js.map} +3 -3
- package/dist/chunk-87qab82w.js +5 -0
- package/dist/chunk-87qab82w.js.map +37 -0
- package/dist/{chunk-36pg61vt.js → chunk-8gew8b9a.js} +2 -2
- package/dist/{chunk-36pg61vt.js.map → chunk-8gew8b9a.js.map} +1 -1
- package/dist/{chunk-v6v6sem5.js → chunk-9m2tbf3n.js} +2 -2
- package/dist/{chunk-v6v6sem5.js.map → chunk-9m2tbf3n.js.map} +1 -1
- package/dist/{chunk-gzdf2025.js → chunk-a2sgjpvq.js} +2 -2
- package/dist/{chunk-gzdf2025.js.map → chunk-a2sgjpvq.js.map} +1 -1
- package/dist/chunk-b35e128b.js +5 -0
- package/dist/{chunk-b50zmz3t.js.map → chunk-b35e128b.js.map} +1 -1
- package/dist/{chunk-ct274qts.js → chunk-cyaz97p5.js} +2 -2
- package/dist/{chunk-ct274qts.js.map → chunk-cyaz97p5.js.map} +1 -1
- package/dist/{chunk-v06qcyj5.js → chunk-d125j8t0.js} +3 -3
- package/dist/{chunk-v06qcyj5.js.map → chunk-d125j8t0.js.map} +1 -1
- package/dist/{chunk-hs5v3eqj.js → chunk-dgsgjg53.js} +2 -2
- package/dist/{chunk-hs5v3eqj.js.map → chunk-dgsgjg53.js.map} +1 -1
- package/dist/{chunk-xjy5apyr.js → chunk-eejmhtnc.js} +2 -2
- package/dist/{chunk-xjy5apyr.js.map → chunk-eejmhtnc.js.map} +1 -1
- package/dist/{chunk-3y75q5a2.js → chunk-fjm4y8bn.js} +2 -2
- package/dist/{chunk-3y75q5a2.js.map → chunk-fjm4y8bn.js.map} +1 -1
- package/dist/{chunk-9c89q2mz.js → chunk-grdahng8.js} +2 -2
- package/dist/{chunk-9c89q2mz.js.map → chunk-grdahng8.js.map} +1 -1
- package/dist/{chunk-699z6d8y.js → chunk-gw6agevz.js} +3 -3
- package/dist/{chunk-699z6d8y.js.map → chunk-gw6agevz.js.map} +1 -1
- package/dist/{chunk-62ke19q4.js → chunk-hppagzz4.js} +4 -4
- package/dist/{chunk-62ke19q4.js.map → chunk-hppagzz4.js.map} +1 -1
- package/dist/chunk-hwa5sqw5.js +19 -0
- package/dist/{chunk-tmnhkphv.js.map → chunk-hwa5sqw5.js.map} +12 -6
- package/dist/chunk-hxf1re93.js +4 -0
- package/dist/{chunk-vkngcrzq.js.map → chunk-hxf1re93.js.map} +6 -5
- package/dist/{chunk-d36dfqxw.js → chunk-j0c6ytkj.js} +3 -3
- package/dist/{chunk-d36dfqxw.js.map → chunk-j0c6ytkj.js.map} +1 -1
- package/dist/{chunk-kgmr9qxx.js → chunk-jhkjz9jr.js} +2 -2
- package/dist/{chunk-npg72mez.js.map → chunk-jhkjz9jr.js.map} +1 -1
- package/dist/{chunk-62723jyy.js → chunk-k0fvsyeh.js} +1 -1
- package/dist/{chunk-31kcf7dq.js → chunk-keehyx51.js} +2 -2
- package/dist/{chunk-31kcf7dq.js.map → chunk-keehyx51.js.map} +1 -1
- package/dist/{chunk-enhkf60v.js → chunk-m3xy5xyf.js} +2 -2
- package/dist/{chunk-enhkf60v.js.map → chunk-m3xy5xyf.js.map} +1 -1
- package/dist/{chunk-c75mymmq.js → chunk-mkfpnymy.js} +1 -1
- package/dist/{chunk-rgb69nh1.js → chunk-mwpdp09e.js} +2 -2
- package/dist/{chunk-rgb69nh1.js.map → chunk-mwpdp09e.js.map} +1 -1
- package/dist/chunk-pmhd6zfc.js +37 -0
- package/dist/chunk-pmhd6zfc.js.map +20 -0
- package/dist/chunk-qb5mv6pj.js +5 -0
- package/dist/chunk-qb5mv6pj.js.map +12 -0
- package/dist/{chunk-1pwwrpa3.js → chunk-qgxr0g36.js} +2 -2
- package/dist/{chunk-1pwwrpa3.js.map → chunk-qgxr0g36.js.map} +1 -1
- package/dist/{chunk-4yafsffx.js → chunk-sy7jbdeb.js} +2 -2
- package/dist/{chunk-4yafsffx.js.map → chunk-sy7jbdeb.js.map} +1 -1
- package/dist/{chunk-gasdfwva.js → chunk-szss069z.js} +2 -2
- package/dist/{chunk-gasdfwva.js.map → chunk-szss069z.js.map} +1 -1
- package/dist/{chunk-m0ggfy1m.js → chunk-tss5svjr.js} +2 -2
- package/dist/{chunk-m0ggfy1m.js.map → chunk-tss5svjr.js.map} +1 -1
- package/dist/{chunk-dzzmqv0j.js → chunk-vr90r27j.js} +2 -2
- package/dist/{chunk-dzzmqv0j.js.map → chunk-vr90r27j.js.map} +1 -1
- package/dist/{chunk-pvdbrt4z.js → chunk-w62m5f0n.js} +3 -3
- package/dist/{chunk-pvdbrt4z.js.map → chunk-w62m5f0n.js.map} +1 -1
- package/dist/{chunk-cn2r5jfj.js → chunk-w7rf99w6.js} +2 -2
- package/dist/{chunk-cn2r5jfj.js.map → chunk-w7rf99w6.js.map} +1 -1
- package/dist/{chunk-tja0c815.js → chunk-wbrj0gya.js} +2 -2
- package/dist/{chunk-tja0c815.js.map → chunk-wbrj0gya.js.map} +1 -1
- package/dist/{chunk-rsdg619q.js → chunk-xdv1b8mr.js} +2 -2
- package/dist/{chunk-rsdg619q.js.map → chunk-xdv1b8mr.js.map} +1 -1
- package/dist/{chunk-h3mwgbg7.js → chunk-xey9cbap.js} +2 -2
- package/dist/{chunk-h3mwgbg7.js.map → chunk-xey9cbap.js.map} +1 -1
- package/dist/{chunk-wgpa04jb.js → chunk-xzk827r3.js} +2 -2
- package/dist/{chunk-wgpa04jb.js.map → chunk-xzk827r3.js.map} +1 -1
- package/dist/{chunk-x14sk95v.js → chunk-y6a8r2bn.js} +3 -3
- package/dist/{chunk-x14sk95v.js.map → chunk-y6a8r2bn.js.map} +1 -1
- package/dist/{chunk-33wjsw4r.js → chunk-yed5whgs.js} +3 -3
- package/dist/{chunk-33wjsw4r.js.map → chunk-yed5whgs.js.map} +1 -1
- package/dist/{chunk-02gdzs5t.js → chunk-yf7vz71n.js} +1 -1
- package/dist/{chunk-y9fp58bg.js → chunk-yjzs247s.js} +2 -2
- package/dist/{chunk-y9fp58bg.js.map → chunk-yjzs247s.js.map} +1 -1
- package/dist/{chunk-kgg1eqne.js → chunk-yy0eb9wn.js} +2 -2
- package/dist/{chunk-kgg1eqne.js.map → chunk-yy0eb9wn.js.map} +1 -1
- package/dist/{chunk-4xx78ba9.js → chunk-zbxgbr12.js} +2 -2
- package/dist/{chunk-4xx78ba9.js.map → chunk-zbxgbr12.js.map} +1 -1
- package/dist/{chunk-8k1zqrvh.js → chunk-zh2egcyb.js} +2 -2
- package/dist/{chunk-8k1zqrvh.js.map → chunk-zh2egcyb.js.map} +1 -1
- package/dist/chunks/ThemeProvider-li1J_igh.js.map +1 -1
- package/dist/client/ClientRouter.d.ts.map +1 -1
- package/dist/client/ProgressManager.d.ts +1 -1
- package/dist/client/RouteStateContext.d.ts +9 -0
- package/dist/client/RouteStateContext.d.ts.map +1 -1
- package/dist/client/ServerDataProvider.d.ts +7 -0
- package/dist/client/ServerDataProvider.d.ts.map +1 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +68 -10
- package/dist/client/index.js.map +1 -1
- package/dist/client/rpc.d.ts +42 -0
- package/dist/client/rpc.d.ts.map +1 -1
- package/dist/client/useFeature.d.ts +40 -0
- package/dist/client/useFeature.d.ts.map +1 -0
- package/dist/config/index.js +1 -1
- package/dist/console/run.js +2 -2
- package/dist/console/run.js.map +1 -1
- package/dist/container/index.js +2 -2
- package/dist/container/index.js.map +1 -1
- package/dist/database/index.js +2 -2
- package/dist/database/index.js.map +1 -1
- package/dist/email/index.js +2 -2
- package/dist/email/index.js.map +1 -1
- package/dist/facades/Features.d.ts +57 -0
- package/dist/facades/Features.d.ts.map +1 -0
- package/dist/facades/index.d.ts +1 -0
- package/dist/facades/index.d.ts.map +1 -1
- package/dist/facades/index.js +2 -2
- package/dist/facades/index.js.map +1 -1
- package/dist/foundation/index.js +2 -2
- package/dist/foundation/index.js.map +1 -1
- package/dist/gemi.d.ts +13 -5
- package/dist/http/ApiRouter.d.ts +3 -2
- package/dist/http/ApiRouter.d.ts.map +1 -1
- package/dist/http/HttpRequest.d.ts +4 -0
- package/dist/http/HttpRequest.d.ts.map +1 -1
- package/dist/http/ViewRouter.d.ts +127 -4
- package/dist/http/ViewRouter.d.ts.map +1 -1
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +2 -2
- package/dist/http/index.js.map +1 -1
- package/dist/http/middlewareList.d.ts +25 -0
- package/dist/http/middlewareList.d.ts.map +1 -0
- package/dist/http/requestContext.d.ts +57 -0
- package/dist/http/requestContext.d.ts.map +1 -1
- package/dist/i18n/dictionaryRuntime.js +2 -2
- package/dist/i18n/dictionaryRuntime.js.map +1 -1
- package/dist/i18n/index.js +2 -2
- package/dist/i18n/index.js.map +1 -1
- package/dist/ide/typescript-plugin/index.js +1206 -0
- package/dist/ide/typescript-plugin/index.js.map +17 -0
- package/dist/kernel/index.js +3 -3
- package/dist/kernel/index.js.map +3 -3
- package/dist/kernel/providers.d.ts +16 -4
- package/dist/kernel/providers.d.ts.map +1 -1
- package/dist/orm/context.d.ts +65 -0
- package/dist/orm/context.d.ts.map +1 -1
- package/dist/orm/index.js +2 -2
- package/dist/orm/index.js.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/services/discovery.d.ts +27 -0
- package/dist/services/discovery.d.ts.map +1 -1
- package/dist/services/events/Event.d.ts +208 -0
- package/dist/services/events/Event.d.ts.map +1 -0
- package/dist/services/events/EventManager.d.ts +285 -0
- package/dist/services/events/EventManager.d.ts.map +1 -0
- package/dist/services/events/EventServiceProvider.d.ts +46 -0
- package/dist/services/events/EventServiceProvider.d.ts.map +1 -0
- package/dist/services/events/FakeEventManager.d.ts +155 -0
- package/dist/services/events/FakeEventManager.d.ts.map +1 -0
- package/dist/services/events/FakeEventManager.test-d.d.ts +2 -0
- package/dist/services/events/FakeEventManager.test-d.d.ts.map +1 -0
- package/dist/services/events/Listener.d.ts +189 -0
- package/dist/services/events/Listener.d.ts.map +1 -0
- package/dist/services/events/Listener.test-d.d.ts +2 -0
- package/dist/services/events/Listener.test-d.d.ts.map +1 -0
- package/dist/services/events/config.d.ts +55 -0
- package/dist/services/events/config.d.ts.map +1 -0
- package/dist/services/events/listenerJob.d.ts +43 -0
- package/dist/services/events/listenerJob.d.ts.map +1 -0
- package/dist/services/features/FeatureFlagStore.d.ts +60 -0
- package/dist/services/features/FeatureFlagStore.d.ts.map +1 -0
- package/dist/services/features/FeatureManager.d.ts +82 -0
- package/dist/services/features/FeatureManager.d.ts.map +1 -0
- package/dist/services/features/FeaturesServiceProvider.d.ts +6 -0
- package/dist/services/features/FeaturesServiceProvider.d.ts.map +1 -0
- package/dist/services/features/bucket.d.ts +57 -0
- package/dist/services/features/bucket.d.ts.map +1 -0
- package/dist/services/features/config.d.ts +57 -0
- package/dist/services/features/config.d.ts.map +1 -0
- package/dist/services/features/context.d.ts +31 -0
- package/dist/services/features/context.d.ts.map +1 -0
- package/dist/services/features/defineFeature.d.ts +145 -0
- package/dist/services/features/defineFeature.d.ts.map +1 -0
- package/dist/services/features/evaluate.d.ts +74 -0
- package/dist/services/features/evaluate.d.ts.map +1 -0
- package/dist/services/features/sources/DatabaseFeatureFlagSource.d.ts +19 -0
- package/dist/services/features/sources/DatabaseFeatureFlagSource.d.ts.map +1 -0
- package/dist/services/features/sources/FeatureFlagSource.d.ts +30 -0
- package/dist/services/features/sources/FeatureFlagSource.d.ts.map +1 -0
- package/dist/services/features/sources/StaticFeatureFlagSource.d.ts +24 -0
- package/dist/services/features/sources/StaticFeatureFlagSource.d.ts.map +1 -0
- package/dist/services/features/types.d.ts +63 -0
- package/dist/services/features/types.d.ts.map +1 -0
- package/dist/services/index.d.ts +18 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +8 -8
- package/dist/services/index.js.map +8 -4
- package/dist/services/queue/QueueManager.d.ts +31 -0
- package/dist/services/queue/QueueManager.d.ts.map +1 -1
- package/dist/services/router/ViewRouteDispatcher.d.ts +8 -0
- package/dist/services/router/ViewRouteDispatcher.d.ts.map +1 -1
- package/dist/services/router/createFlatViewRoutes.d.ts +14 -0
- package/dist/services/router/createFlatViewRoutes.d.ts.map +1 -1
- package/dist/services/router/streamQueryInjection.d.ts.map +1 -1
- package/dist/support/index.js +2 -2
- package/dist/support/index.js.map +1 -1
- package/dist/testing/Page.d.ts +12 -0
- package/dist/testing/Page.d.ts.map +1 -1
- package/dist/testing/index.d.ts +24 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +7 -3
- package/dist/testing/index.js.map +1 -1
- package/ide/typescript-plugin/package.json +5 -0
- package/package.json +4 -2
- package/dist/chunk-4t80js0n.js +0 -33
- package/dist/chunk-4t80js0n.js.map +0 -18
- package/dist/chunk-98a576s9.js +0 -5
- package/dist/chunk-98a576s9.js.map +0 -30
- package/dist/chunk-b50zmz3t.js +0 -5
- package/dist/chunk-cv9w5cmb.js +0 -6
- package/dist/chunk-f9mfw82d.js +0 -5
- package/dist/chunk-f9mfw82d.js.map +0 -12
- package/dist/chunk-kry5vwam.js +0 -9
- package/dist/chunk-tmnhkphv.js +0 -19
- package/dist/chunk-vkngcrzq.js +0 -4
- /package/dist/{chunk-62723jyy.js.map → chunk-k0fvsyeh.js.map} +0 -0
- /package/dist/{chunk-c75mymmq.js.map → chunk-mkfpnymy.js.map} +0 -0
- /package/dist/{chunk-02gdzs5t.js.map → chunk-yf7vz71n.js.map} +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Event, EventClass } from "./Event";
|
|
2
|
+
/**
|
|
3
|
+
* One side effect of one event, in its own file.
|
|
4
|
+
*
|
|
5
|
+
* ```typescript
|
|
6
|
+
* // app/listeners/SendWelcomeEmail.ts
|
|
7
|
+
* export class SendWelcomeEmail extends Listener {
|
|
8
|
+
* static name = "SendWelcomeEmail";
|
|
9
|
+
* static event = UserRegistered;
|
|
10
|
+
*
|
|
11
|
+
* async handle(event: UserRegistered) {
|
|
12
|
+
* await Mail.send(event.email, ...);
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* That is the whole of what the subsystem buys: adding a fourth side effect to
|
|
18
|
+
* a registration is adding a file, rather than editing the controller that
|
|
19
|
+
* already has three.
|
|
20
|
+
*
|
|
21
|
+
* ### Why the binding lives here and not on the event
|
|
22
|
+
*
|
|
23
|
+
* The listener is the thing with an opinion about what it cares about; an event
|
|
24
|
+
* has no business knowing who is watching. An event listing its listeners would
|
|
25
|
+
* also put the property back the way it was — adding a side effect would edit
|
|
26
|
+
* an existing file.
|
|
27
|
+
*
|
|
28
|
+
* ### Why the binding is a value at all
|
|
29
|
+
*
|
|
30
|
+
* Laravel binds a listener to an event by reflecting on the type-hint of
|
|
31
|
+
* `handle(UserRegistered $event)`. TypeScript erases types at runtime, so that
|
|
32
|
+
* mechanism cannot exist here and the binding has to be carried as a value.
|
|
33
|
+
* That constraint turns out to be a favourable one: it is also what makes the
|
|
34
|
+
* payload types flow without a generated registry.
|
|
35
|
+
*
|
|
36
|
+
* ### The seam, written down rather than hidden
|
|
37
|
+
*
|
|
38
|
+
* **Nothing checks that `static event` and the annotation on `handle` agree.**
|
|
39
|
+
* A static and an instance member cannot reference each other's types, so the
|
|
40
|
+
* compiler sees `static event = UserRegistered` and `handle(event: OrderPaid)`
|
|
41
|
+
* as two unrelated declarations. Copy a listener, change the static, forget the
|
|
42
|
+
* annotation, and TypeScript is satisfied while the listener receives something
|
|
43
|
+
* else.
|
|
44
|
+
*
|
|
45
|
+
* It is accepted because the failure is local and immediate — that one listener
|
|
46
|
+
* reads a field that is not there, in its own stack — rather than the
|
|
47
|
+
* misroute-shaped failures the rest of this subsystem is arranged against. The
|
|
48
|
+
* `Listener.test-d.ts` beside this file pins the two halves the compiler *does*
|
|
49
|
+
* enforce, so a later refactor reaching for convenience cannot widen them to
|
|
50
|
+
* `any` unnoticed.
|
|
51
|
+
*/
|
|
52
|
+
export declare abstract class Listener {
|
|
53
|
+
/**
|
|
54
|
+
* The name this listener is reported and de-duplicated under. Required.
|
|
55
|
+
*
|
|
56
|
+
* Two listener classes claiming one name is refused at registration, and a
|
|
57
|
+
* discovery walk makes that ordinary: `auth/NotifyAdmins.ts` beside
|
|
58
|
+
* `billing/NotifyAdmins.ts` is a natural thing to write, and nothing forces
|
|
59
|
+
* the import alias a hand-written list would have demanded.
|
|
60
|
+
*
|
|
61
|
+
* As on `Event`, the `"unset"` default is a floor and not the check — a class
|
|
62
|
+
* declaration always shadows it with its own implicit binding, which is the
|
|
63
|
+
* one a minifier renames. `discoverListeners` reads the property descriptor
|
|
64
|
+
* to tell a declared name from an implicit one.
|
|
65
|
+
*
|
|
66
|
+
* For a **queued** listener it is more than a label: the queue is keyed by
|
|
67
|
+
* name, the listener is registered under `listener:<name>`, and that string
|
|
68
|
+
* is what a queued dispatch carries. So a queued listener whose name is the
|
|
69
|
+
* implicit class binding is refused at registration rather than warned about
|
|
70
|
+
* — the two ends of the queue can be two different module graphs, and a name
|
|
71
|
+
* only one of them minified is a side effect that stops happening in
|
|
72
|
+
* production and reports success.
|
|
73
|
+
*/
|
|
74
|
+
static name: string;
|
|
75
|
+
/**
|
|
76
|
+
* The event class this listener handles. Exactly one, required.
|
|
77
|
+
*
|
|
78
|
+
* It holds the **class**, not its name. The name is read off it once, at
|
|
79
|
+
* registration, inside `EventManager.useListeners` — and that read happens in
|
|
80
|
+
* the module graph that declared the class, so the name it yields is the
|
|
81
|
+
* source one. Nothing downstream keeps the class object, because a registry
|
|
82
|
+
* keyed by class identity is wrong in production only.
|
|
83
|
+
*
|
|
84
|
+
* There is no `static events = [A, B]`, deliberately. A listener bound to two
|
|
85
|
+
* events has to discriminate inside `handle`, and the natural way to write
|
|
86
|
+
* that is `if (event instanceof UserRegistered)` — which is `true` in every
|
|
87
|
+
* test and `false` in a production build, for the reason `Event`'s own doc
|
|
88
|
+
* comment gives. Two events wanting the same side effect are two small
|
|
89
|
+
* listeners calling one shared function.
|
|
90
|
+
*
|
|
91
|
+
* Declared as required, and still checked at runtime: every subclass inherits
|
|
92
|
+
* this declaration whether or not it assigns to it, so the compiler cannot
|
|
93
|
+
* see a listener that left it out. `EventManager` refuses one out loud.
|
|
94
|
+
*/
|
|
95
|
+
static event: EventClass;
|
|
96
|
+
/**
|
|
97
|
+
* Where this listener runs. **A context boundary, not a performance dial.**
|
|
98
|
+
*
|
|
99
|
+
* Left `false`, the listener runs inline, inside the dispatcher's
|
|
100
|
+
* `kernelContext` and `ormContext`: it has the request's `app()`, the
|
|
101
|
+
* authenticated user through `currentActor()`, and it joins the ambient
|
|
102
|
+
* transaction.
|
|
103
|
+
*
|
|
104
|
+
* **Unless the event declares `static afterCommit`.** That flag releases its
|
|
105
|
+
* listeners after the commit and outside the transaction's scope, so a sync
|
|
106
|
+
* listener bound to one runs with `currentTransaction()` undefined and
|
|
107
|
+
* commits on its own. The listener kept sync *because* it writes a row that
|
|
108
|
+
* has to roll back with the write it describes — an audit trail, a ledger
|
|
109
|
+
* entry — starts committing separately the day someone sets that flag in the
|
|
110
|
+
* event's file, and nothing on this side says so: no error, no warning, and
|
|
111
|
+
* a `queued = false` that still reads as "inside the transaction".
|
|
112
|
+
*
|
|
113
|
+
* Set `true`, it is handed to the `QueueManager` instead and run from a
|
|
114
|
+
* drain, on the queue's terms. What crosses is the event's name and its
|
|
115
|
+
* constructor arguments as JSON, and nothing else — not the instance the sync
|
|
116
|
+
* listeners share, and not a line of the request. With `worker = true` it is
|
|
117
|
+
* a different thread with a cloned application.
|
|
118
|
+
*
|
|
119
|
+
* The part that catches people is that the context is not reliably *gone*
|
|
120
|
+
* either: the queue is in-process, so a drain that happens to start from
|
|
121
|
+
* `push` is still standing in the dispatcher's context and `app()` there
|
|
122
|
+
* resolves the request's application. Nothing about that is promised. A
|
|
123
|
+
* queued listener that reads the current actor, or writes expecting to join
|
|
124
|
+
* the ambient transaction, works until the day the queue was already busy —
|
|
125
|
+
* and then reads different rows, or commits separately, with no error either
|
|
126
|
+
* way. So: a listener that needs the request's context has to stay sync, and
|
|
127
|
+
* a listener that only needs the payload is free to queue.
|
|
128
|
+
*
|
|
129
|
+
* What it buys, in exchange, is the queue's whole retry path: `maxAttempts`,
|
|
130
|
+
* `onFail`-style re-queueing and dead-lettering, none of which a sync
|
|
131
|
+
* listener has. `dispatchAndWait` does **not** wait for it.
|
|
132
|
+
*/
|
|
133
|
+
queued: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Attempts before the queue gives up, counting the first. `Job`'s field and
|
|
136
|
+
* `Job`'s meaning, because it is forwarded to one.
|
|
137
|
+
*
|
|
138
|
+
* **Ignored unless `queued` is true.** A sync listener has no retry path at
|
|
139
|
+
* all — its throw is logged and the next listener runs — so a `maxAttempts`
|
|
140
|
+
* beside `queued = false` is a line that does nothing, which is why it is
|
|
141
|
+
* said here rather than in a note somewhere else.
|
|
142
|
+
*/
|
|
143
|
+
maxAttempts: number;
|
|
144
|
+
/**
|
|
145
|
+
* Runs `handle` in a Worker thread with its own cloned application, for a
|
|
146
|
+
* queued listener whose work is CPU-bound. `Job`'s field and `Job`'s
|
|
147
|
+
* meaning, because it is forwarded to one.
|
|
148
|
+
*
|
|
149
|
+
* **Ignored unless `queued` is true.** A sync listener runs on the
|
|
150
|
+
* dispatcher's stack by definition; there is no thread to move it to.
|
|
151
|
+
*/
|
|
152
|
+
worker: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* The side effect. Runs inside the dispatcher's context when `queued` is
|
|
155
|
+
* false: a sync listener has the request's `app()`, its authenticated user,
|
|
156
|
+
* and — unless the event declares `static afterCommit` — its ambient
|
|
157
|
+
* transaction. A queued one can rely on none of them — see `queued`.
|
|
158
|
+
*
|
|
159
|
+
* Annotate the parameter with the event named in `static event` — narrowing
|
|
160
|
+
* the base's `Event` here is legal because method parameters are bivariant,
|
|
161
|
+
* which is what lets this class avoid a generic parameter. Nothing checks
|
|
162
|
+
* that the two agree; see the note on the class.
|
|
163
|
+
*
|
|
164
|
+
* Abstract, so a listener that forgets it fails to compile rather than
|
|
165
|
+
* silently handling nothing.
|
|
166
|
+
*
|
|
167
|
+
* A throw is caught, logged with both names, and the next listener still
|
|
168
|
+
* runs. Listeners are independent side effects by construction, and their
|
|
169
|
+
* order is a filesystem walk's — letting one cancel the rest would make that
|
|
170
|
+
* order load-bearing, which is the exact coupling this subsystem exists to
|
|
171
|
+
* remove.
|
|
172
|
+
*/
|
|
173
|
+
abstract handle(event: Event): void | Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* A `Listener` subclass, as the registry and the `events` config slice hold
|
|
177
|
+
* one.
|
|
178
|
+
*
|
|
179
|
+
* Zero-argument, because the manager constructs one per dispatch and has
|
|
180
|
+
* nothing to pass it. The `event` member is typed as present even though
|
|
181
|
+
* `EventManager` checks for it at runtime: an inherited declaration is
|
|
182
|
+
* indistinguishable from an assignment to the type system, so this is the
|
|
183
|
+
* shape, and the runtime check is what covers the difference.
|
|
184
|
+
*/
|
|
185
|
+
export type ListenerClass = (new () => Listener) & {
|
|
186
|
+
name: string;
|
|
187
|
+
event: EventClass;
|
|
188
|
+
};
|
|
189
|
+
//# sourceMappingURL=Listener.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Listener.d.ts","sourceRoot":"","sources":["../../../services/events/Listener.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,8BAAsB,QAAQ;IAC5B;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,MAAM,CAAC,IAAI,SAAW;IAEtB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,MAAM,CAAC,KAAK,EAAE,UAAU,CAAC;IAEzB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,MAAM,UAAS;IAEf;;;;;;;;OAQG;IACH,WAAW,SAAK;IAEhB;;;;;;;OAOG;IACH,MAAM,UAAS;IAEf;;;;;;;;;;;;;;;;;;;OAmBG;IACH,QAAQ,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;CACpD;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,UAAU,QAAQ,CAAC,GAAG;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,UAAU,CAAC;CACnB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Listener.test-d.d.ts","sourceRoot":"","sources":["../../../services/events/Listener.test-d.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ListenerClass } from "./Listener";
|
|
2
|
+
export interface EventConfig {
|
|
3
|
+
/**
|
|
4
|
+
* The `Listener` subclasses this application registers, or nothing.
|
|
5
|
+
*
|
|
6
|
+
* ### Why "or nothing" is the point
|
|
7
|
+
*
|
|
8
|
+
* A dispatch fans out to whatever the `EventManager` holds for the event's
|
|
9
|
+
* name, and an event nothing is registered for is not an error — it is the
|
|
10
|
+
* ordinary state of an application that has not written that listener yet.
|
|
11
|
+
* So this list is a second spelling of `app/listeners`, kept in step by hand,
|
|
12
|
+
* and what the two disagreeing costs is a side effect that stops happening,
|
|
13
|
+
* with a development-only warning as the only trace. Nothing fails, nothing
|
|
14
|
+
* is dropped from a log: the welcome email simply is not sent.
|
|
15
|
+
*
|
|
16
|
+
* Leaving this out spells it once: the listeners are the classes under
|
|
17
|
+
* `listenersDir`.
|
|
18
|
+
*
|
|
19
|
+
* ### The rule, exactly
|
|
20
|
+
*
|
|
21
|
+
* **Declared wins, and `[]` is declared.** A `listeners` that is present is
|
|
22
|
+
* used verbatim and no directory is read — that is the escape hatch for an
|
|
23
|
+
* app whose listeners live somewhere this cannot walk, for one that
|
|
24
|
+
* deliberately registers a subset, and for a deploy that ships no source. An
|
|
25
|
+
* empty array means an app with no listeners and says so; it does not mean
|
|
26
|
+
* "find some".
|
|
27
|
+
*
|
|
28
|
+
* **Absent or `undefined` discovers.** `undefined` counts as absent for the
|
|
29
|
+
* same reason `withDefaults` treats it that way everywhere else: a key spread
|
|
30
|
+
* in from an optional value is an omission, not an instruction.
|
|
31
|
+
*/
|
|
32
|
+
listeners?: ListenerClass[];
|
|
33
|
+
/**
|
|
34
|
+
* Where to look when `listeners` was not declared. Relative to the project
|
|
35
|
+
* root, or absolute.
|
|
36
|
+
*
|
|
37
|
+
* Every `.ts`/`.tsx` file underneath it is imported at boot and every
|
|
38
|
+
* exported class extending `Listener` is registered, so this wants to be a
|
|
39
|
+
* directory of listener declarations rather than a directory that merely
|
|
40
|
+
* contains some. That includes an abstract base a few listeners share: the
|
|
41
|
+
* walk excludes the framework's `Listener` and nothing else, so a base
|
|
42
|
+
* sitting here is registered alongside its subclasses and its `handle` runs
|
|
43
|
+
* on every dispatch of whatever event it declares. Keep shared bases outside
|
|
44
|
+
* this directory.
|
|
45
|
+
*
|
|
46
|
+
* There is no `eventsDir` to go with it. Event classes are never discovered
|
|
47
|
+
* — each is imported by the listener that binds to it and by the code that
|
|
48
|
+
* dispatches it, so nothing needs to walk them and a walk that imported them
|
|
49
|
+
* anyway would only be a boot cost.
|
|
50
|
+
*/
|
|
51
|
+
listenersDir?: string;
|
|
52
|
+
}
|
|
53
|
+
export declare function defineEventConfig(config: EventConfig): EventConfig;
|
|
54
|
+
export declare function eventConfigDefaults(): Required<EventConfig>;
|
|
55
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../services/events/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAGhD,MAAM,WAAW,WAAW;IAC1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,SAAS,CAAC,EAAE,aAAa,EAAE,CAAC;IAE5B;;;;;;;;;;;;;;;;;OAiBG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,GAAG,WAAW,CAElE;AAED,wBAAgB,mBAAmB,IAAI,QAAQ,CAAC,WAAW,CAAC,CAK3D"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Job } from "../queue/Job";
|
|
2
|
+
import type { Listener, ListenerClass } from "./Listener";
|
|
3
|
+
/**
|
|
4
|
+
* The adapter that lets `queued = true` be one line: a `Job` subclass, built at
|
|
5
|
+
* registration, that runs exactly one listener.
|
|
6
|
+
*
|
|
7
|
+
* Retries, `maxAttempts`, dead-lettering, `concurrency` and worker-thread
|
|
8
|
+
* execution are four features the queue already has, already documents for
|
|
9
|
+
* applications, and is already tested on. A second queue for listeners would be
|
|
10
|
+
* four features that are almost the same as those, differing in ways nobody
|
|
11
|
+
* decided. So a queued listener is not queued by anything written here — it is
|
|
12
|
+
* a job, and everything after `push` is the queue's.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The name a listener's synthetic job is registered under.
|
|
16
|
+
*
|
|
17
|
+
* Prefixed, and visibly so. The synthetic jobs land in
|
|
18
|
+
* `QueueManager.registeredJobs` alongside the app's own, because that getter is
|
|
19
|
+
* documented as reporting what the manager was handed and a queued listener
|
|
20
|
+
* genuinely is something the queue will run — hiding them would make a queue
|
|
21
|
+
* introspection tool lie about what is about to execute. The prefix is what
|
|
22
|
+
* keeps an author from reading `SendWelcomeEmail` there and looking for a job
|
|
23
|
+
* file they never wrote.
|
|
24
|
+
*/
|
|
25
|
+
export declare function listenerJobName(listenerName: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Builds the `Job` subclass that runs one queued listener.
|
|
28
|
+
*
|
|
29
|
+
* `instance` is the listener the caller already constructed to read `queued`
|
|
30
|
+
* off; the three fields that decide where the work runs are read from that one
|
|
31
|
+
* instance rather than from a fresh one per attempt, so a listener's
|
|
32
|
+
* constructor runs once at boot instead of once per retry.
|
|
33
|
+
*
|
|
34
|
+
* What crosses the queue is `[eventName, constructorArguments]` as JSON — a
|
|
35
|
+
* name and the arguments, never the event instance and never the classes. That
|
|
36
|
+
* is invariant 1 at its second boundary: the class objects on the pushing side
|
|
37
|
+
* and the running side can come from two different module graphs (a minified
|
|
38
|
+
* bundle and a source-side discovery walk), so a name declared as a string
|
|
39
|
+
* literal is the only thing both ends agree on. `EventManager.rehydrate` turns
|
|
40
|
+
* it back into an event.
|
|
41
|
+
*/
|
|
42
|
+
export declare function jobForListener(listener: ListenerClass, instance: Listener): new () => Job;
|
|
43
|
+
//# sourceMappingURL=listenerJob.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listenerJob.d.ts","sourceRoot":"","sources":["../../../services/events/listenerJob.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAEnC,OAAO,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE1D;;;;;;;;;;GAUG;AAEH;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,aAAa,EACvB,QAAQ,EAAE,QAAQ,GACjB,UAAU,GAAG,CAiFf"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { FeatureRegistry } from "./defineFeature";
|
|
2
|
+
import type { FeatureFlagSource } from "./sources/FeatureFlagSource";
|
|
3
|
+
export type Warn = (message: string) => void;
|
|
4
|
+
export interface FlagSnapshot {
|
|
5
|
+
/** `key -> active`. A key absent from the map has no row, and is off. */
|
|
6
|
+
active: Map<string, boolean>;
|
|
7
|
+
loadedAt: number;
|
|
8
|
+
/** True only while nothing has *ever* loaded successfully. */
|
|
9
|
+
unavailable: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The process-local cache of on/off switches.
|
|
13
|
+
*
|
|
14
|
+
* gemi has no `Cache` facade, and this does not add one — a general cache
|
|
15
|
+
* abstraction is a much larger design, and coupling features to a hypothetical
|
|
16
|
+
* version of it would block both.
|
|
17
|
+
*
|
|
18
|
+
* Three properties, each load-bearing:
|
|
19
|
+
*
|
|
20
|
+
* **Stale-while-revalidate.** After the first load, `get()` returns an
|
|
21
|
+
* already-resolved snapshot and refreshes in the background. No request ever
|
|
22
|
+
* waits on the database for a feature. This is what makes evaluating every
|
|
23
|
+
* feature on every request affordable, and it is why the manager can be eager.
|
|
24
|
+
*
|
|
25
|
+
* **Single-flight.** A cold start under load must not issue one query per
|
|
26
|
+
* in-flight request, so concurrent refreshes share one promise.
|
|
27
|
+
*
|
|
28
|
+
* **A failed refresh keeps the last good data.** An outage must not read as
|
|
29
|
+
* "every feature switched itself off" — that is a config change nobody made,
|
|
30
|
+
* applied to production, at the exact moment something else is already broken.
|
|
31
|
+
*/
|
|
32
|
+
export declare class FeatureFlagStore {
|
|
33
|
+
private readonly source;
|
|
34
|
+
private readonly declared;
|
|
35
|
+
private readonly ttlMs;
|
|
36
|
+
private readonly warn;
|
|
37
|
+
private snapshot;
|
|
38
|
+
private inflight;
|
|
39
|
+
/** Rate-limits the "could not load" line to once per TTL window. */
|
|
40
|
+
private lastFailureLoggedAt;
|
|
41
|
+
constructor(source: FeatureFlagSource, declared: FeatureRegistry, ttlMs: number, warn?: Warn);
|
|
42
|
+
/** Never rejects. Callers always get a snapshot, possibly an empty one. */
|
|
43
|
+
get(): Promise<FlagSnapshot>;
|
|
44
|
+
/** Forces a reload now, sharing one query across concurrent callers. */
|
|
45
|
+
refresh(): Promise<FlagSnapshot>;
|
|
46
|
+
/** What is cached right now, without triggering a load. */
|
|
47
|
+
peek(): FlagSnapshot | null;
|
|
48
|
+
private load;
|
|
49
|
+
/**
|
|
50
|
+
* Rows in, `key -> active` out.
|
|
51
|
+
*
|
|
52
|
+
* The row is treated as hostile input — not because anyone expects it to be
|
|
53
|
+
* malformed, but because it is the one part of this system nobody reviews
|
|
54
|
+
* before it reaches production. A bad row is logged and skipped, never thrown:
|
|
55
|
+
* a typo in a column must not take the process down at boot.
|
|
56
|
+
*/
|
|
57
|
+
private readSwitches;
|
|
58
|
+
private handleFailure;
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=FeatureFlagStore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeatureFlagStore.d.ts","sourceRoot":"","sources":["../../../services/features/FeatureFlagStore.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAErE,MAAM,MAAM,IAAI,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;AAE7C,MAAM,WAAW,YAAY;IAC3B,yEAAyE;IACzE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,WAAW,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,gBAAgB;IAOzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,IAAI;IATvB,OAAO,CAAC,QAAQ,CAA6B;IAC7C,OAAO,CAAC,QAAQ,CAAsC;IACtD,oEAAoE;IACpE,OAAO,CAAC,mBAAmB,CAAK;gBAGb,MAAM,EAAE,iBAAiB,EACzB,QAAQ,EAAE,eAAe,EACzB,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,IAAe;IAGxC,2EAA2E;IACrE,GAAG,IAAI,OAAO,CAAC,YAAY,CAAC;IAiBlC,wEAAwE;IACxE,OAAO,IAAI,OAAO,CAAC,YAAY,CAAC;IAOhC,2DAA2D;IAC3D,IAAI,IAAI,YAAY,GAAG,IAAI;YAIb,IAAI;IAclB;;;;;;;OAOG;IACH,OAAO,CAAC,YAAY;IA0BpB,OAAO,CAAC,aAAa;CAkBtB"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ResolvedFeaturesConfig } from "./config";
|
|
2
|
+
import { type FeatureSubject } from "./context";
|
|
3
|
+
import type { FeatureRegistry } from "./defineFeature";
|
|
4
|
+
import { FeatureFlagStore } from "./FeatureFlagStore";
|
|
5
|
+
import type { FeatureContext, FeatureEvaluation } from "./types";
|
|
6
|
+
/**
|
|
7
|
+
* Evaluation bound to one explicit context — what `Features.for(...)` returns.
|
|
8
|
+
*
|
|
9
|
+
* Exists because a job, a cron tick or an admin preview needs to ask "what would
|
|
10
|
+
* *this* user see", and the ambient-request path cannot answer that.
|
|
11
|
+
*/
|
|
12
|
+
export declare class FeatureScope {
|
|
13
|
+
private readonly manager;
|
|
14
|
+
private readonly ctx;
|
|
15
|
+
constructor(manager: FeatureManager, ctx: FeatureContext);
|
|
16
|
+
enabled(key: string): Promise<boolean>;
|
|
17
|
+
explain(key: string): Promise<FeatureEvaluation>;
|
|
18
|
+
all(): Promise<Record<string, boolean>>;
|
|
19
|
+
}
|
|
20
|
+
export declare class FeatureManager {
|
|
21
|
+
readonly config: ResolvedFeaturesConfig;
|
|
22
|
+
private readonly log;
|
|
23
|
+
static token: string;
|
|
24
|
+
readonly store: FeatureFlagStore;
|
|
25
|
+
private readonly declared;
|
|
26
|
+
private warnedAboutSize;
|
|
27
|
+
constructor(config: ResolvedFeaturesConfig, log?: (message: string) => void);
|
|
28
|
+
/** The declarations, for a CLI or an admin surface. Server-side only. */
|
|
29
|
+
declarations(): FeatureRegistry;
|
|
30
|
+
/** Reloads the snapshot in this process now. */
|
|
31
|
+
refresh(): Promise<void>;
|
|
32
|
+
enabled(key: string): Promise<boolean>;
|
|
33
|
+
/**
|
|
34
|
+
* Value plus why. Server-side only — `reason` says whether the viewer landed
|
|
35
|
+
* in a rollout or was targeted by name, and must never be serialized.
|
|
36
|
+
*/
|
|
37
|
+
explain(key: string): Promise<FeatureEvaluation>;
|
|
38
|
+
/** Every declared feature for the ambient request, as `key -> boolean`. */
|
|
39
|
+
all(): Promise<Record<string, boolean>>;
|
|
40
|
+
/**
|
|
41
|
+
* What the SSR payload carries: client-visible features only.
|
|
42
|
+
*
|
|
43
|
+
* The single function the dispatcher calls, and the only place the server-only
|
|
44
|
+
* exclusion is applied — so "what reaches the browser" has one answer in one
|
|
45
|
+
* place rather than a rule each caller has to remember.
|
|
46
|
+
*/
|
|
47
|
+
forClient(): Promise<Record<string, boolean>>;
|
|
48
|
+
/** Evaluation against an explicit subject, for jobs, cron and previews. */
|
|
49
|
+
for(subject: FeatureSubject): FeatureScope;
|
|
50
|
+
/**
|
|
51
|
+
* @internal — shared by the ambient path and `FeatureScope`. The single place
|
|
52
|
+
* a feature is evaluated, and therefore the single place the per-request memo
|
|
53
|
+
* is consulted and `onEvaluate` fires.
|
|
54
|
+
*
|
|
55
|
+
* Both of those used to live in `explain()`, one level up, which meant every
|
|
56
|
+
* caller that did not go through it — `forClient()` building the SSR payload,
|
|
57
|
+
* `passesFeatureGates` checking a route — evaluated afresh and notified again.
|
|
58
|
+
* A request that read a feature in a handler and then rendered it emitted two
|
|
59
|
+
* exposures for one viewer, quietly doubling whatever counted them.
|
|
60
|
+
*/
|
|
61
|
+
evaluateIn(key: string, ctx: FeatureContext, buckets: Map<string, number>): Promise<FeatureEvaluation>;
|
|
62
|
+
/** @internal */
|
|
63
|
+
evaluateAllIn(ctx: FeatureContext, buckets: Map<string, number>, options: {
|
|
64
|
+
clientOnly: boolean;
|
|
65
|
+
}): Promise<Record<string, boolean>>;
|
|
66
|
+
private switchFor;
|
|
67
|
+
private requestContext;
|
|
68
|
+
/**
|
|
69
|
+
* The request's evaluation memo, or `null` when there is none to use.
|
|
70
|
+
*
|
|
71
|
+
* Gated on the context being the ambient request's *own*, by identity. A
|
|
72
|
+
* `Features.for({ user })` inside a request is asking what somebody else would
|
|
73
|
+
* see, and answering it from — or writing it into — the current viewer's memo
|
|
74
|
+
* would cross the two: an admin previewing a customer would poison every
|
|
75
|
+
* subsequent read on that request with the customer's values.
|
|
76
|
+
*/
|
|
77
|
+
private memoFor;
|
|
78
|
+
private requestBuckets;
|
|
79
|
+
private notify;
|
|
80
|
+
private warnIfOversized;
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=FeatureManager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeatureManager.d.ts","sourceRoot":"","sources":["../../../services/features/FeatureManager.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,EAA0C,KAAK,cAAc,EAAE,MAAM,WAAW,CAAC;AACxF,OAAO,KAAK,EAAW,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEhE,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,KAAK,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAEjE;;;;;GAKG;AACH,qBAAa,YAAY;IAErB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBADH,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,cAAc;IAGhC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAItC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAIhD,GAAG,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAK9C;AAED,qBAAa,cAAc;IAQvB,QAAQ,CAAC,MAAM,EAAE,sBAAsB;IACvC,OAAO,CAAC,QAAQ,CAAC,GAAG;IARtB,MAAM,CAAC,KAAK,SAAc;IAE1B,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;IACjC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkB;IAC3C,OAAO,CAAC,eAAe,CAAS;gBAGrB,MAAM,EAAE,sBAAsB,EACtB,GAAG,GAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAe;IAe5D,yEAAyE;IACzE,YAAY,IAAI,eAAe;IAI/B,gDAAgD;IAC1C,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAUxB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI5C;;;OAGG;IACG,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAKtD,2EAA2E;IACrE,GAAG,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAO7C;;;;;;OAMG;IACG,SAAS,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAWnD,2EAA2E;IAC3E,GAAG,CAAC,OAAO,EAAE,cAAc,GAAG,YAAY;IAI1C;;;;;;;;;;OAUG;IACG,UAAU,CACd,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,cAAc,EACnB,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC3B,OAAO,CAAC,iBAAiB,CAAC;IAyB7B,gBAAgB;IACV,aAAa,CACjB,GAAG,EAAE,cAAc,EACnB,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE;QAAE,UAAU,EAAE,OAAO,CAAA;KAAE,GAC/B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YAWrB,SAAS;YAgBT,cAAc;IAS5B;;;;;;;;OAQG;IACH,OAAO,CAAC,OAAO;IAOf,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,MAAM;IAcd,OAAO,CAAC,eAAe;CAOxB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FeaturesServiceProvider.d.ts","sourceRoot":"","sources":["../../../services/features/FeaturesServiceProvider.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAMhE,qBAAa,uBAAwB,SAAQ,eAAe;IAC1D,QAAQ;IAqCF,IAAI;CAkBX"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic bucketing for percentage rollouts.
|
|
3
|
+
*
|
|
4
|
+
* ## Why nothing is stored
|
|
5
|
+
*
|
|
6
|
+
* A subject's bucket is a pure function of the feature and the subject, so it is
|
|
7
|
+
* the same on every machine, in every process, on every device that subject logs
|
|
8
|
+
* in from, forever — without a row, a cookie, or a cache holding the assignment.
|
|
9
|
+
* Persisting assignments would be strictly worse: it would be per-browser rather
|
|
10
|
+
* than per-subject, it would need migrating whenever a feature changes, and it
|
|
11
|
+
* would grow with the user count.
|
|
12
|
+
*
|
|
13
|
+
* ## Why SHA-1 and not `Bun.hash`
|
|
14
|
+
*
|
|
15
|
+
* `Bun.hash` is faster and wrong for this. Its output is seeded and is not a
|
|
16
|
+
* documented stable contract across Bun versions, so a runtime upgrade could
|
|
17
|
+
* silently re-bucket every subject in the middle of a rollout — the 10% who had
|
|
18
|
+
* the new checkout become a different 10%, and nobody finds out from a stack
|
|
19
|
+
* trace. SHA-1 is a fixed standard: the same string maps to the same bucket on
|
|
20
|
+
* every machine, every process and every version. This is a correctness
|
|
21
|
+
* requirement, not a security one, so SHA-1's collision weakness is irrelevant
|
|
22
|
+
* here — and `Bun.CryptoHasher("sha1")` is already how `server/generateEtag.ts`
|
|
23
|
+
* hashes.
|
|
24
|
+
*
|
|
25
|
+
* ## What is in the key
|
|
26
|
+
*
|
|
27
|
+
* `salt:subject`, where `salt` defaults to the feature's key.
|
|
28
|
+
*
|
|
29
|
+
* The salt is what decorrelates features from each other. Without it two
|
|
30
|
+
* independent 20% rollouts would select the *same* 20% of subjects, so a user
|
|
31
|
+
* unlucky once would be unlucky in everything and the two populations could
|
|
32
|
+
* never be reasoned about separately.
|
|
33
|
+
*/
|
|
34
|
+
declare const RESOLUTION = 10000;
|
|
35
|
+
/** The bucket key. Exported so a test can pin exact strings to exact buckets. */
|
|
36
|
+
export declare function bucketKey(salt: string, subject: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* A stable integer in `[0, RESOLUTION)` — i.e. 0.01% granularity.
|
|
39
|
+
*
|
|
40
|
+
* Takes the first 32 bits of the digest. That is four orders of magnitude more
|
|
41
|
+
* entropy than the 10,000 buckets it is reduced to, so the modulo bias is far
|
|
42
|
+
* below the noise floor of any rollout anyone would configure.
|
|
43
|
+
*/
|
|
44
|
+
export declare function bucketOf(key: string): number;
|
|
45
|
+
/**
|
|
46
|
+
* Whether `bucket` falls inside `percent`.
|
|
47
|
+
*
|
|
48
|
+
* `bucket < threshold` rather than a range test, which buys monotonicity for
|
|
49
|
+
* free: a subject inside a 10% rollout is still inside the same feature's 20%
|
|
50
|
+
* rollout. Ramping up therefore only ever *adds* people. The alternative —
|
|
51
|
+
* anything that reshuffles on each change — means a user who saw the feature at
|
|
52
|
+
* 10% can lose it at 20%, which reads as a bug to them and invalidates any
|
|
53
|
+
* measurement taken across the change.
|
|
54
|
+
*/
|
|
55
|
+
export declare function inRollout(bucket: number, percent: number): boolean;
|
|
56
|
+
export { RESOLUTION };
|
|
57
|
+
//# sourceMappingURL=bucket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bucket.d.ts","sourceRoot":"","sources":["../../../services/features/bucket.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,QAAA,MAAM,UAAU,QAAS,CAAC;AAE1B,iFAAiF;AACjF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI5C;AAED;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAIlE;AAED,OAAO,EAAE,UAAU,EAAE,CAAC"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { HttpRequest } from "../../http/HttpRequest";
|
|
2
|
+
import type { FeatureRegistry } from "./defineFeature";
|
|
3
|
+
import type { FeatureFlagSource } from "./sources/FeatureFlagSource";
|
|
4
|
+
import type { FeatureContext, FeatureEvaluation } from "./types";
|
|
5
|
+
export interface FeaturesConfig {
|
|
6
|
+
/**
|
|
7
|
+
* The application's declarations — the default export of
|
|
8
|
+
* `app/features/index.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Named here as well as in the framework's `gemi.d.ts` because the two layers
|
|
11
|
+
* cannot share one reference: the type augmentation resolves `@/app/features`
|
|
12
|
+
* through the app's tsconfig paths, which exists only at compile time. This is
|
|
13
|
+
* the runtime half. They should point at the same object.
|
|
14
|
+
*/
|
|
15
|
+
features?: FeatureRegistry;
|
|
16
|
+
/**
|
|
17
|
+
* Master switch. `false` skips loading and evaluation entirely: every feature
|
|
18
|
+
* reads off and the SSR payload carries `{}`.
|
|
19
|
+
*/
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
/** Where the on/off switches come from. The database by default. */
|
|
22
|
+
source?: FeatureFlagSource;
|
|
23
|
+
/** ORM registry name of the model, when using the database source. */
|
|
24
|
+
model?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Snapshot lifetime in **seconds**.
|
|
27
|
+
*
|
|
28
|
+
* This is the propagation delay: switching a feature on or off is live on
|
|
29
|
+
* every instance within this window. Lower it if that matters more than the
|
|
30
|
+
* query volume — there is no cross-instance invalidation.
|
|
31
|
+
*/
|
|
32
|
+
ttl?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Extra attributes for every evaluation — country, cohort, build, plan.
|
|
35
|
+
* Reachable in a `when` as `ctx.attributes`.
|
|
36
|
+
*
|
|
37
|
+
* Runs on every request inside the render path, so keep it cheap and free of
|
|
38
|
+
* I/O. If it throws, evaluation degrades to no attributes rather than failing
|
|
39
|
+
* the page.
|
|
40
|
+
*/
|
|
41
|
+
context?: (req: HttpRequest | null) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
42
|
+
/**
|
|
43
|
+
* Called once per key per request after evaluation — the hook an analytics or
|
|
44
|
+
* experiment pipeline reads exposures from. Errors are caught and logged.
|
|
45
|
+
*/
|
|
46
|
+
onEvaluate?: (key: string, evaluation: FeatureEvaluation, ctx: FeatureContext) => void;
|
|
47
|
+
/** Warn once per boot above this many client-visible features. */
|
|
48
|
+
maxClientFlags?: number;
|
|
49
|
+
}
|
|
50
|
+
export declare function defineFeaturesConfig(config: FeaturesConfig): FeaturesConfig;
|
|
51
|
+
export declare function featuresConfigDefaults(): Required<Omit<FeaturesConfig, "features" | "context" | "onEvaluate">> & {
|
|
52
|
+
features: FeaturesConfig["features"];
|
|
53
|
+
context: FeaturesConfig["context"];
|
|
54
|
+
onEvaluate: FeaturesConfig["onEvaluate"];
|
|
55
|
+
};
|
|
56
|
+
export type ResolvedFeaturesConfig = ReturnType<typeof featuresConfigDefaults>;
|
|
57
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../services/features/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AACrE,OAAO,KAAK,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAGjE,MAAM,WAAW,cAAc;IAC7B;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,eAAe,CAAC;IAE3B;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAElB,oEAAoE;IACpE,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAE3B,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAElG;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,iBAAiB,EAAE,GAAG,EAAE,cAAc,KAAK,IAAI,CAAC;IAEvF,kEAAkE;IAClE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,cAAc,CAE3E;AAED,wBAAgB,sBAAsB,IAAI,QAAQ,CAChD,IAAI,CAAC,cAAc,EAAE,UAAU,GAAG,SAAS,GAAG,YAAY,CAAC,CAC5D,GAAG;IACF,QAAQ,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACrC,OAAO,EAAE,cAAc,CAAC,SAAS,CAAC,CAAC;IACnC,UAAU,EAAE,cAAc,CAAC,YAAY,CAAC,CAAC;CAC1C,CAWA;AAED,MAAM,MAAM,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ResolvedFeaturesConfig } from "./config";
|
|
2
|
+
import type { FeatureContext } from "./types";
|
|
3
|
+
export interface FeatureSubject {
|
|
4
|
+
user?: Record<string, any> | null;
|
|
5
|
+
attributes?: Record<string, unknown>;
|
|
6
|
+
/** The bucketing subject when there is no user — an org id, a device id. */
|
|
7
|
+
subjectId?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Builds the context a feature is evaluated against from the ambient request.
|
|
11
|
+
*
|
|
12
|
+
* Two decisions worth stating:
|
|
13
|
+
*
|
|
14
|
+
* **The user comes from the request store, not `Auth.user()`.** `Auth.user()`
|
|
15
|
+
* throws `AuthenticationError` when nobody is signed in and performs a session
|
|
16
|
+
* lookup to find out. Features have to be evaluable on an anonymous marketing
|
|
17
|
+
* page without either, so this reads `store.user` and accepts `null`. The
|
|
18
|
+
* consequence is worth knowing: on a route with no `auth` middleware, where
|
|
19
|
+
* nothing has resolved a session, `ctx.user` is `null` and a `when` that reads
|
|
20
|
+
* it will not match. The fix an application reaches for is the `auth` middleware
|
|
21
|
+
* it already has.
|
|
22
|
+
*
|
|
23
|
+
* **No request is not an error.** A job, a cron tick or a console command has no
|
|
24
|
+
* store, and `Features.enabled()` there answers with an anonymous context rather
|
|
25
|
+
* than throwing. `Features.for({ user })` is the explicit form when a background
|
|
26
|
+
* task needs to evaluate as somebody.
|
|
27
|
+
*/
|
|
28
|
+
export declare function contextFromRequest(config: ResolvedFeaturesConfig, warn?: (message: string) => void): Promise<FeatureContext>;
|
|
29
|
+
/** The same shape, from an explicit subject rather than the ambient request. */
|
|
30
|
+
export declare function contextFromSubject(subject: FeatureSubject): FeatureContext;
|
|
31
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../../services/features/context.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,sBAAsB,EAC9B,IAAI,GAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAe,GACzC,OAAO,CAAC,cAAc,CAAC,CAyBzB;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,cAAc,GAAG,cAAc,CAc1E"}
|