@shware/workflow 7.0.0 → 9.0.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/README.md +125 -35
- package/dist/aws/engine-step.cjs +4 -1
- package/dist/aws/engine-step.cjs.map +1 -1
- package/dist/aws/engine-step.d.cts.map +1 -1
- package/dist/aws/engine-step.d.mts.map +1 -1
- package/dist/aws/engine-step.mjs +4 -1
- package/dist/aws/engine-step.mjs.map +1 -1
- package/dist/aws/index.cjs +3 -3
- package/dist/aws/index.cjs.map +1 -1
- package/dist/aws/index.mjs +3 -3
- package/dist/aws/index.mjs.map +1 -1
- package/dist/client.cjs +66 -41
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +72 -22
- package/dist/client.d.cts.map +1 -1
- package/dist/client.d.mts +72 -22
- package/dist/client.d.mts.map +1 -1
- package/dist/client.mjs +66 -42
- package/dist/client.mjs.map +1 -1
- package/dist/cloudflare/index.d.cts +5 -4
- package/dist/cloudflare/index.d.mts +5 -4
- package/dist/cloudflare/runner.cjs +3 -1
- package/dist/cloudflare/runner.cjs.map +1 -1
- package/dist/cloudflare/runner.d.cts.map +1 -1
- package/dist/cloudflare/runner.d.mts.map +1 -1
- package/dist/cloudflare/runner.mjs +3 -1
- package/dist/cloudflare/runner.mjs.map +1 -1
- package/dist/cloudflare/worker.cjs +12 -1
- package/dist/cloudflare/worker.cjs.map +1 -1
- package/dist/cloudflare/worker.d.cts +9 -1
- package/dist/cloudflare/worker.d.cts.map +1 -1
- package/dist/cloudflare/worker.d.mts +9 -1
- package/dist/cloudflare/worker.d.mts.map +1 -1
- package/dist/cloudflare/worker.mjs +12 -1
- package/dist/cloudflare/worker.mjs.map +1 -1
- package/dist/contract.cjs.map +1 -1
- package/dist/contract.d.cts +4 -4
- package/dist/contract.d.mts +4 -4
- package/dist/contract.mjs.map +1 -1
- package/dist/dsl/trigger.cjs +2 -1
- package/dist/dsl/trigger.cjs.map +1 -1
- package/dist/dsl/trigger.d.cts +6 -0
- package/dist/dsl/trigger.d.cts.map +1 -1
- package/dist/dsl/trigger.d.mts +6 -0
- package/dist/dsl/trigger.d.mts.map +1 -1
- package/dist/dsl/trigger.mjs +2 -1
- package/dist/dsl/trigger.mjs.map +1 -1
- package/dist/engine/condition.cjs +3 -3
- package/dist/engine/condition.cjs.map +1 -1
- package/dist/engine/condition.d.cts +3 -3
- package/dist/engine/condition.d.mts +3 -3
- package/dist/engine/condition.mjs +3 -3
- package/dist/engine/condition.mjs.map +1 -1
- package/dist/engine/interpreter.cjs +9 -5
- package/dist/engine/interpreter.cjs.map +1 -1
- package/dist/engine/interpreter.d.cts.map +1 -1
- package/dist/engine/interpreter.d.mts.map +1 -1
- package/dist/engine/interpreter.mjs +9 -5
- package/dist/engine/interpreter.mjs.map +1 -1
- package/dist/engine/ports.d.cts +2 -0
- package/dist/engine/ports.d.cts.map +1 -1
- package/dist/engine/ports.d.mts +2 -0
- package/dist/engine/ports.d.mts.map +1 -1
- package/dist/host/batch.cjs +172 -0
- package/dist/host/batch.cjs.map +1 -0
- package/dist/host/batch.d.cts +88 -0
- package/dist/host/batch.d.cts.map +1 -0
- package/dist/host/batch.d.mts +88 -0
- package/dist/host/batch.d.mts.map +1 -0
- package/dist/host/batch.mjs +163 -0
- package/dist/host/batch.mjs.map +1 -0
- package/dist/host/execute.cjs +10 -6
- package/dist/host/execute.cjs.map +1 -1
- package/dist/host/execute.mjs +10 -6
- package/dist/host/execute.mjs.map +1 -1
- package/dist/host/http.cjs +30 -89
- package/dist/host/http.cjs.map +1 -1
- package/dist/host/http.d.cts +1 -1
- package/dist/host/http.d.cts.map +1 -1
- package/dist/host/http.d.mts +1 -1
- package/dist/host/http.d.mts.map +1 -1
- package/dist/host/http.mjs +24 -83
- package/dist/host/http.mjs.map +1 -1
- package/dist/host/ingest.cjs +84 -35
- package/dist/host/ingest.cjs.map +1 -1
- package/dist/host/ingest.d.cts +29 -16
- package/dist/host/ingest.d.cts.map +1 -1
- package/dist/host/ingest.d.mts +29 -16
- package/dist/host/ingest.d.mts.map +1 -1
- package/dist/host/ingest.mjs +83 -36
- package/dist/host/ingest.mjs.map +1 -1
- package/dist/host/journey-host.cjs +6 -0
- package/dist/host/journey-host.cjs.map +1 -1
- package/dist/host/journey-host.d.cts +5 -2
- package/dist/host/journey-host.d.cts.map +1 -1
- package/dist/host/journey-host.d.mts +5 -2
- package/dist/host/journey-host.d.mts.map +1 -1
- package/dist/host/journey-host.mjs +6 -0
- package/dist/host/journey-host.mjs.map +1 -1
- package/dist/host/platform.cjs.map +1 -1
- package/dist/host/platform.d.cts +2 -0
- package/dist/host/platform.d.cts.map +1 -1
- package/dist/host/platform.d.mts +2 -0
- package/dist/host/platform.d.mts.map +1 -1
- package/dist/host/platform.mjs.map +1 -1
- package/dist/ir.cjs +2 -1
- package/dist/ir.cjs.map +1 -1
- package/dist/ir.d.cts +3 -0
- package/dist/ir.d.cts.map +1 -1
- package/dist/ir.d.mts +3 -0
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +2 -1
- package/dist/ir.mjs.map +1 -1
- package/dist/store/condition-sql.cjs +144 -0
- package/dist/store/condition-sql.cjs.map +1 -0
- package/dist/store/condition-sql.d.cts +38 -0
- package/dist/store/condition-sql.d.cts.map +1 -0
- package/dist/store/condition-sql.d.mts +38 -0
- package/dist/store/condition-sql.d.mts.map +1 -0
- package/dist/store/condition-sql.mjs +143 -0
- package/dist/store/condition-sql.mjs.map +1 -0
- package/dist/store/facts.cjs +13 -9
- package/dist/store/facts.cjs.map +1 -1
- package/dist/store/facts.d.cts +6 -2
- package/dist/store/facts.d.cts.map +1 -1
- package/dist/store/facts.d.mts +6 -2
- package/dist/store/facts.d.mts.map +1 -1
- package/dist/store/facts.mjs +13 -9
- package/dist/store/facts.mjs.map +1 -1
- package/dist/store/identity.cjs +34 -0
- package/dist/store/identity.cjs.map +1 -0
- package/dist/store/identity.d.cts +13 -0
- package/dist/store/identity.d.cts.map +1 -0
- package/dist/store/identity.d.mts +13 -0
- package/dist/store/identity.d.mts.map +1 -0
- package/dist/store/identity.mjs +30 -0
- package/dist/store/identity.mjs.map +1 -0
- package/dist/store/index.cjs +5 -0
- package/dist/store/index.cjs.map +1 -1
- package/dist/store/index.d.cts +67 -21
- package/dist/store/index.d.cts.map +1 -1
- package/dist/store/index.d.mts +67 -21
- package/dist/store/index.d.mts.map +1 -1
- package/dist/store/index.mjs +2 -1
- package/dist/store/index.mjs.map +1 -1
- package/dist/store/postgres.cjs +248 -54
- package/dist/store/postgres.cjs.map +1 -1
- package/dist/store/postgres.d.cts +25 -7
- package/dist/store/postgres.d.cts.map +1 -1
- package/dist/store/postgres.d.mts +25 -7
- package/dist/store/postgres.d.mts.map +1 -1
- package/dist/store/postgres.mjs +249 -55
- package/dist/store/postgres.mjs.map +1 -1
- package/dist/store/tables.cjs +24 -6
- package/dist/store/tables.cjs.map +1 -1
- package/dist/store/tables.d.cts +1 -0
- package/dist/store/tables.d.cts.map +1 -1
- package/dist/store/tables.d.mts +1 -0
- package/dist/store/tables.d.mts.map +1 -1
- package/dist/store/tables.mjs +24 -6
- package/dist/store/tables.mjs.map +1 -1
- package/dist/webpush.cjs.map +1 -1
- package/dist/webpush.d.cts +1 -1
- package/dist/webpush.d.mts +1 -1
- package/dist/webpush.mjs.map +1 -1
- package/package.json +1 -1
package/dist/ir.d.mts
CHANGED
|
@@ -194,6 +194,7 @@ export declare const TriggerIR: z.ZodMiniDiscriminatedUnion<[z.ZodMiniObject<{
|
|
|
194
194
|
event: z.ZodMiniString<string>;
|
|
195
195
|
where: z.ZodMiniOptional<z.ZodMiniType<ConditionIR, unknown, z.core.$ZodTypeInternals<ConditionIR, unknown>>>;
|
|
196
196
|
filter: z.ZodMiniOptional<z.ZodMiniType<ConditionIR, unknown, z.core.$ZodTypeInternals<ConditionIR, unknown>>>;
|
|
197
|
+
visitors: z.ZodMiniOptional<z.ZodMiniLiteral<true>>;
|
|
197
198
|
}, z.core.$strip>, z.ZodMiniObject<{
|
|
198
199
|
type: z.ZodMiniLiteral<"segment">;
|
|
199
200
|
segment: z.ZodMiniString<string>;
|
|
@@ -337,6 +338,7 @@ export declare const WorkflowIR: z.ZodMiniObject<{
|
|
|
337
338
|
event: z.ZodMiniString<string>;
|
|
338
339
|
where: z.ZodMiniOptional<z.ZodMiniType<ConditionIR, unknown, z.core.$ZodTypeInternals<ConditionIR, unknown>>>;
|
|
339
340
|
filter: z.ZodMiniOptional<z.ZodMiniType<ConditionIR, unknown, z.core.$ZodTypeInternals<ConditionIR, unknown>>>;
|
|
341
|
+
visitors: z.ZodMiniOptional<z.ZodMiniLiteral<true>>;
|
|
340
342
|
}, z.core.$strip>, z.ZodMiniObject<{
|
|
341
343
|
type: z.ZodMiniLiteral<"segment">;
|
|
342
344
|
segment: z.ZodMiniString<string>;
|
|
@@ -432,6 +434,7 @@ export declare const BundleIR: z.ZodMiniObject<{
|
|
|
432
434
|
event: z.ZodMiniString<string>;
|
|
433
435
|
where: z.ZodMiniOptional<z.ZodMiniType<ConditionIR, unknown, z.core.$ZodTypeInternals<ConditionIR, unknown>>>;
|
|
434
436
|
filter: z.ZodMiniOptional<z.ZodMiniType<ConditionIR, unknown, z.core.$ZodTypeInternals<ConditionIR, unknown>>>;
|
|
437
|
+
visitors: z.ZodMiniOptional<z.ZodMiniLiteral<true>>;
|
|
435
438
|
}, z.core.$strip>, z.ZodMiniObject<{
|
|
436
439
|
type: z.ZodMiniLiteral<"segment">;
|
|
437
440
|
segment: z.ZodMiniString<string>;
|
package/dist/ir.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ir.d.mts","names":[],"sources":["../src/ir.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBA6Ca;qBAIA,UAAQ,EAAA,uBAAA,EAAA,uBAAA,EAAA,uBAAA,EAAA;YACT,WAAW,EAAE,aAAa;;qBAUzB,YAAU,EAAA;;;GAAkD,EAAA,KAAA;YAC7D,aAAa,EAAE,aAAa;;qBAG3B,mBAAiB,EAAA;;;GAG5B,EAAA,KAAA;YACU,oBAAoB,EAAE,aAAa;;qBAGlC,aAAW,EAAA,uBAAA,EAAA,uBAAA,EAAA,uBAAA,EAAA,uBAAA,EAAA,2BAAA,EAAA;;;GAAyC,EAAA,KAAA;YACrD,cAAc,EAAE,aAAa;qBAE5B,WAAS,EAAA;;;;;;;;;YACV,YAAY,EAAE,aAAa;qBAE1B,WAAS,EAAA;;;;;;;;;YACV,YAAY,EAAE,aAAa;;;;;;;qBAU1B,aAAW,EAAA;;;;GAItB,EAAA,KAAA;YACU,cAAc,EAAE,aAAa;;qBAG5B,YAAU,EAAA;;;;;;GAErB,EAAA,KAAA;YACU,aAAa,EAAE,aAAa;qBAI3B,oBAAkB,EAAA;;;;;;;;;;;;;;;;YAgBnB,qBAAqB,EAAE,aAAa;;;;;;;;;;;;;;;;;UAkBtC;EACR,OAAO;;YAGG,eACP;EAAoB;EAAa,YAAY;MAC7C;EAAoB;EAAY,YAAY;MAC5C;EAAoB;EAAa,WAAW;;;CAE5C;EAAoB;EAAiB;MACrC;EACC;EACA;EACA,IAAI;EACJ,QAAQ;EACR,SAAS;MAEV;;EAEC;EACA;EACA,IAAI;EACJ,QAAQ;EACR,SAAS;MAEV;EACC;EACA;;EAEA,QAAQ;EACR,SAAS;EACT;;;;;;;CAOD;EAAoB;EAAmB,SAAS;;qBAIxC,aAAa,EAAE,YAAY;;;;;;qBAyC3B,WAAS,EAAA,2BAAA,EAAA
|
|
1
|
+
{"version":3,"file":"ir.d.mts","names":[],"sources":["../src/ir.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBA6Ca;qBAIA,UAAQ,EAAA,uBAAA,EAAA,uBAAA,EAAA,uBAAA,EAAA;YACT,WAAW,EAAE,aAAa;;qBAUzB,YAAU,EAAA;;;GAAkD,EAAA,KAAA;YAC7D,aAAa,EAAE,aAAa;;qBAG3B,mBAAiB,EAAA;;;GAG5B,EAAA,KAAA;YACU,oBAAoB,EAAE,aAAa;;qBAGlC,aAAW,EAAA,uBAAA,EAAA,uBAAA,EAAA,uBAAA,EAAA,uBAAA,EAAA,2BAAA,EAAA;;;GAAyC,EAAA,KAAA;YACrD,cAAc,EAAE,aAAa;qBAE5B,WAAS,EAAA;;;;;;;;;YACV,YAAY,EAAE,aAAa;qBAE1B,WAAS,EAAA;;;;;;;;;YACV,YAAY,EAAE,aAAa;;;;;;;qBAU1B,aAAW,EAAA;;;;GAItB,EAAA,KAAA;YACU,cAAc,EAAE,aAAa;;qBAG5B,YAAU,EAAA;;;;;;GAErB,EAAA,KAAA;YACU,aAAa,EAAE,aAAa;qBAI3B,oBAAkB,EAAA;;;;;;;;;;;;;;;;YAgBnB,qBAAqB,EAAE,aAAa;;;;;;;;;;;;;;;;;UAkBtC;EACR,OAAO;;YAGG,eACP;EAAoB;EAAa,YAAY;MAC7C;EAAoB;EAAY,YAAY;MAC5C;EAAoB;EAAa,WAAW;;;CAE5C;EAAoB;EAAiB;MACrC;EACC;EACA;EACA,IAAI;EACJ,QAAQ;EACR,SAAS;MAEV;;EAEC;EACA;EACA,IAAI;EACJ,QAAQ;EACR,SAAS;MAEV;EACC;EACA;;EAEA,QAAQ;EACR,SAAS;EACT;;;;;;;CAOD;EAAoB;EAAmB,SAAS;;qBAIxC,aAAa,EAAE,YAAY;;;;;;qBAyC3B,WAAS,EAAA,2BAAA,EAAA;;;;;;;;;;;;;;GAWpB,EAAA,KAAA;YACU,YAAY,EAAE,aAAa;UAI7B;;EAER;;EAEA;;EAEA,OAAO;;YAGG,UACP;EACC;EACA,SAAS;EACT;EACA,OAAO,eAAe;;;;;EAKtB;;;;;;EAMA;MAED;EAAe;EAAe,UAAU;MACxC;EAAe;EAAsB,KAAK;EAAY,KAAK;MAC3D;EACC;EACA,MAAM;EACN;EACA;MAED;EACC;EACA,WAAW;EACX,SAAS;EACT,iCAAiC;MAElC;EACC;EACA;IAAS;IAA4B,WAAW;IAAa,MAAM;;;EAEnE,YAAY;MAEb;EACC;;EAEA;EACA,WAAW;MAEZ;EACC;;;;;;;EAOA;;;;;;;EAOA;IAAQ;IAAc;IAAgB,MAAM;;MAE7C;;EAEC;EACA;MAED;EAAe;EAAoB;EAAe,SAAS,eAAe;MAC1E;EACC;;EAEA;;EAEA,MAAM,eAAe;;qBAKd,QAAQ,EAAE,YAAY;qBAqEtB,QAAM,EAAA;;;;;;;GAIjB,EAAA,KAAA;YACU,SAAS,EAAE,aAAa;;;;;;qBAOvB,QAAM,EAAA;;;;;;;;GAUjB,EAAA,KAAA;YACU,SAAS,EAAE,aAAa;qBAEvB,YAAU,EAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAUrB,EAAA,KAAA;YACU,aAAa,EAAE,aAAa;qBAE3B,WAAS,EAAA;;;;;;;;;;;;;;GAQpB,EAAA,KAAA;YACU,YAAY,EAAE,aAAa;;qBAG1B,YAAU,EAAA;;;;;;;;;;;;GAIrB,EAAA,KAAA;YACU,aAAa,EAAE,aAAa;;;;;;qBAO3B,UAAQ,EAAA;;;GAGnB,EAAA,KAAA;YACU,WAAW,EAAE,aAAa;;;;;;;;;;qBAWzB,iBAAe,EAAA;;GAE1B,EAAA,KAAA;YACU,kBAAkB,EAAE,aAAa;qBAEhC,UAAQ,EAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAQnB,EAAA,KAAA;YACU,WAAW,EAAE,aAAa"}
|
package/dist/ir.mjs
CHANGED
|
@@ -169,7 +169,8 @@ const TriggerIR = z.discriminatedUnion("type", [
|
|
|
169
169
|
type: z.literal("event"),
|
|
170
170
|
event: z.string(),
|
|
171
171
|
where: z.optional(ConditionIR),
|
|
172
|
-
filter: z.optional(ConditionIR)
|
|
172
|
+
filter: z.optional(ConditionIR),
|
|
173
|
+
visitors: z.optional(z.literal(true))
|
|
173
174
|
}),
|
|
174
175
|
z.object({
|
|
175
176
|
type: z.literal("segment"),
|
package/dist/ir.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ir.mjs","names":[],"sources":["../src/ir.ts"],"sourcesContent":["import * as z from 'zod/mini';\n\n/**\n * IR (Intermediate Representation) — what the DSL compiles to: serializable\n * JSON. It is the three-way contract between the engine that interprets it,\n * the UI (react-flow) that renders it, and diff / audit tooling.\n *\n * Validation strategy: the zod/mini schemas are the authoritative definition.\n * TS types are either inferred from a schema or hand-written under the same\n * name (zod cannot infer recursive types; the hand-written type is anchored\n * with a ZodMiniType<T> annotation, so any drift fails to compile). IR crosses\n * process boundaries (database, network, UI), so runtime validation is a must.\n *\n * ## Versioning (two independent axes)\n *\n * 1. IR format version (irVersion): evolution of the IR schema itself. Adding\n * optional fields does not bump it; only structurally incompatible changes\n * do (changing a discriminant, dropping a field, altering semantics).\n * Readers dispatch on irVersion to the matching schema/migrator; writers\n * always write the current version.\n * 2. Content version (contentHash): a content-addressed id for a single\n * definition's **execution semantics** — the canonical-JSON hash after\n * metadata (meta / label / reason) is stripped, see hash.ts. It is used to:\n * - pin a version when a user enters, so an in-flight journey runs the\n * pinned version to completion (the default policy; compatible hot\n * updates are a future explicit-migration topic). Known exception:\n * segment conditions are resolved **by id at evaluation time** — a\n * segment redeploy changes behaviour of in-flight journeys that\n * reference it. Pinning covers the workflow's own structure; per-hash\n * segment snapshots are future work;\n * - diff on deploy: same hash means unchanged, skip publishing;\n * - audit: answer \"which version was this user on\" at any point.\n *\n * ## Node ids\n *\n * Derived from the structural path at compile time — stable and deterministic:\n * root level '0', '1', …; the j-th child of a branch's i-th case is\n * '{branchId}.c{i}.{j}', the default arm '{branchId}.o.{j}'; cohort arms use\n * the arm name '{cohortId}.{armName}.{j}'; a waitUntil timeout flow '{id}.t.{j}'.\n * Editing a node's parameters keeps its id; inserting a node shifts the ids of\n * its later siblings (a known trade-off — locating in-flight users relies on\n * the pinned version rather than cross-version id alignment, and cross-version\n * mapping is an explicit migrator's job).\n */\n\nexport const IR_VERSION = 2;\n\n/* -------------------------------- Base values ------------------------------- */\n\nexport const ScalarIR = z.union([z.string(), z.number(), z.boolean()]);\nexport type ScalarIR = z.infer<typeof ScalarIR>;\n\n/** Duration: `value` keeps the DSL literal ('23 hours', for display), `ms` is what the engine uses. */\n/*\n * TODO: `value` is hashed alongside `ms`, so '7 days' / '1 week' / '168 hours'\n * — identical to the engine, which only reads ms — produce three different\n * contentHashes. Rewording a duration therefore reports as a semantic change in\n * plan and repins in-flight users. Moving `value` out of the hash (it is a\n * display string) would fix it; deferred because it changes existing hashes.\n */\nexport const DurationIR = z.object({ value: z.string(), ms: z.number() });\nexport type DurationIR = z.infer<typeof DurationIR>;\n\n/** User-property reference — isomorphic to the DSL's UserPropertyRef once serialized (phantom type dropped). */\nexport const UserPropertyRefIR = z.object({\n type: z.literal('user_property'),\n path: z.string(),\n});\nexport type UserPropertyRefIR = z.infer<typeof UserPropertyRefIR>;\n\n/** A message prop / event payload value: either a literal or a user-property reference. */\nexport const PropValueIR = z.union([ScalarIR, UserPropertyRefIR]);\nexport type PropValueIR = z.infer<typeof PropValueIR>;\n\nexport const ChannelIR = z.enum(['email', 'sms', 'push', 'in_app', 'slack', 'discord', 'survey']);\nexport type ChannelIR = z.infer<typeof ChannelIR>;\n\nexport const WeekdayIR = z.enum(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);\nexport type WeekdayIR = z.infer<typeof WeekdayIR>;\n\n/* --------------------------------- Provenance -------------------------------- */\n\n/**\n * Source location of the builder call that produced a definition — the IR's\n * source map. Lives under `meta`, so it is **excluded from contentHash**\n * (see hash.ts): provenance never affects execution identity or plan.\n * `file` is cwd-relative when compiled under Node, a URL under Vite dev.\n */\nexport const SourceLocIR = z.object({\n file: z.string(),\n line: z.number(),\n column: z.number(),\n});\nexport type SourceLocIR = z.infer<typeof SourceLocIR>;\n\n/** Node-level metadata: provenance only, for now. Excluded from contentHash. */\nexport const NodeMetaIR = z.object({\n loc: z.optional(SourceLocIR),\n});\nexport type NodeMetaIR = z.infer<typeof NodeMetaIR>;\n\n/* -------------------------------- Conditions -------------------------------- */\n\nexport const PropertyOperatorIR = z.enum([\n 'eq',\n 'ne',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'between',\n 'not_between',\n 'in_array',\n 'not_in_array',\n 'exists',\n 'not_exists',\n 'contains',\n 'not_contains',\n]);\nexport type PropertyOperatorIR = z.infer<typeof PropertyOperatorIR>;\n\n/**\n * Condition expression tree. The shape of value/values follows `op`:\n * eq/ne/gt/gte/lt/lte/contains/not_contains → value; in_array/not_in_array →\n * values; between/not_between → values = [min, max]; exists/not_exists → neither.\n *\n * `payload` predicates test the payload of an event (dotted paths reach nested\n * fields). They are only valid inside a `where` subtree — `performed.where`\n * and the event trigger's `where` — where an event row is in scope; the\n * compiler (compileBundle) and the evaluator both enforce the placement.\n * TODO: runtime refinement narrowing by op.\n */\n/**\n * Provenance rides on conditions too, so the studio can point at the exact\n * `eq(...)` / `and(...)` that produced a node — same rule as nodes: `meta` is\n * dropped before hashing, so carrying it never affects execution identity.\n */\ninterface ConditionBaseIR {\n meta?: NodeMetaIR | undefined;\n}\n\nexport type ConditionIR =\n | (ConditionBaseIR & { type: 'and'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'or'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'not'; condition: ConditionIR })\n /** Reference a segment by id (defined in SegmentIR, versioned separately). */\n | (ConditionBaseIR & { type: 'segment'; segment: string })\n | (ConditionBaseIR & {\n type: 'property';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n /** Event payload predicate; `path` is dotted for nested fields ('tags.utm_source'). */\n type: 'payload';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n type: 'performed';\n event: string;\n /** Payload filter: only events matching it count (a payload-only subtree). */\n where?: ConditionIR | undefined;\n within?: DurationIR | undefined;\n count?: number | undefined;\n })\n /**\n * The user can be reached on the channel right now: for push, the host's\n * device directory lists at least one device; for the others, the channel's\n * recipient property (email, phone, …) is set.\n */\n | (ConditionBaseIR & { type: 'reachable'; channel: ChannelIR });\n\nconst conditionBase = { meta: z.optional(NodeMetaIR) };\n\nexport const ConditionIR: z.ZodMiniType<ConditionIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({ ...conditionBase, type: z.literal('and'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('or'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('not'), condition: ConditionIR }),\n z.object({ ...conditionBase, type: z.literal('segment'), segment: z.string() }),\n z.object({\n ...conditionBase,\n type: z.literal('property'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('payload'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('performed'),\n event: z.string(),\n where: z.optional(ConditionIR),\n within: z.optional(DurationIR),\n count: z.optional(z.number()),\n }),\n z.object({ ...conditionBase, type: z.literal('reachable'), channel: ChannelIR }),\n ])\n);\n\n/* --------------------------------- Triggers --------------------------------- */\n\n/**\n * Trigger shapes. The event trigger has two independent gates: `where` tests\n * the triggering event's payload (payload-only subtree, evaluated against the\n * incoming event before anything else), `filter` tests the user's profile.\n */\nexport const TriggerIR = z.discriminatedUnion('type', [\n z.object({\n type: z.literal('event'),\n event: z.string(),\n where: z.optional(ConditionIR),\n filter: z.optional(ConditionIR),\n }),\n z.object({ type: z.literal('segment'), segment: z.string() }),\n z.object({ type: z.literal('date'), at: z.string() }),\n z.object({ type: z.literal('webhook') }),\n]);\nexport type TriggerIR = z.infer<typeof TriggerIR>;\n\n/* ----------------------------------- Nodes ---------------------------------- */\n\ninterface NodeBaseIR {\n /** Stable id derived from the structural path (rules in the file header). */\n id: string;\n /** Optional node name: UI title / observability handle (branch's optional first argument lands here). */\n label?: string | undefined;\n /** Metadata (provenance); excluded from contentHash. */\n meta?: NodeMetaIR | undefined;\n}\n\nexport type NodeIR =\n | (NodeBaseIR & {\n type: 'message';\n channel: ChannelIR;\n template: string;\n props: Record<string, PropValueIR>;\n /**\n * Push only: which of the user's devices get a copy. Absent means every\n * device the directory lists; 'latest' only the most recently registered.\n */\n devices?: 'latest' | undefined;\n /**\n * Email and push: the template's subscription topic, or null for a transactional message\n * (sent whatever the user opted out of). Absent for other channels, and in IR compiled\n * before topics existed: such a step checks the channel-level opt-out only, as it always did.\n */\n topic?: string | null | undefined;\n })\n | (NodeBaseIR & { type: 'delay'; duration: DurationIR })\n | (NodeBaseIR & { type: 'random_delay'; min: DurationIR; max: DurationIR })\n | (NodeBaseIR & {\n type: 'time_window';\n days: WeekdayIR[];\n between: [string, string];\n tz: string;\n })\n | (NodeBaseIR & {\n type: 'wait_until';\n condition: ConditionIR;\n timeout: DurationIR;\n onTimeout: 'continue' | 'exit' | NodeIR[];\n })\n | (NodeBaseIR & {\n type: 'branch';\n cases: { label?: string | undefined; condition: ConditionIR; flow: NodeIR[] }[];\n /** Default arm (the DSL's bare tail argument). Absent = fall through to the main line. */\n otherwise?: NodeIR[] | undefined;\n })\n | (NodeBaseIR & {\n type: 'filter';\n /** Audit-log line for the users this gate drops. Metadata: excluded from contentHash. */\n reason?: string | undefined;\n condition: ConditionIR;\n })\n | (NodeBaseIR & {\n type: 'cohort';\n /**\n * Explicit experiment key: bucketing hashes `userId:key` instead of the\n * structural node id, so the assignment survives node insertion/moves and\n * the experiment keeps one identity across workflow versions. Absent →\n * the node id is used (fine for one-off splits).\n */\n key?: string | undefined;\n /**\n * Arms in authoring order — the order the interpreter accumulates weights\n * in, so it decides which bucket range each arm owns. The DSL keeps that\n * promise by rejecting integer-like arm names, which JavaScript would\n * hoist to the front of the object in numeric order. Weights sum to 100.\n */\n arms: { name: string; weight: number; flow: NodeIR[] }[];\n })\n | (NodeBaseIR & {\n /** `reason` is the audit-log line. Metadata: excluded from contentHash. */\n type: 'exit';\n reason?: string | undefined;\n })\n | (NodeBaseIR & { type: 'send_event'; event: string; payload: Record<string, PropValueIR> })\n | (NodeBaseIR & {\n type: 'action';\n /** Registry name of the custom action; the code itself lives in the deployed Worker bundle. */\n action: string;\n /** Argument values; user_property references are resolved at run time, like message props. */\n args: Record<string, PropValueIR>;\n });\n\nconst nodeBase = { id: z.string(), label: z.optional(z.string()), meta: z.optional(NodeMetaIR) };\n\nexport const NodeIR: z.ZodMiniType<NodeIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({\n ...nodeBase,\n type: z.literal('message'),\n channel: ChannelIR,\n template: z.string(),\n props: z.record(z.string(), PropValueIR),\n devices: z.optional(z.literal('latest')),\n topic: z.optional(z.nullable(z.string())),\n }),\n z.object({ ...nodeBase, type: z.literal('delay'), duration: DurationIR }),\n z.object({ ...nodeBase, type: z.literal('random_delay'), min: DurationIR, max: DurationIR }),\n z.object({\n ...nodeBase,\n type: z.literal('time_window'),\n days: z.array(WeekdayIR),\n between: z.tuple([z.string(), z.string()]),\n tz: z.string(),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('wait_until'),\n condition: ConditionIR,\n timeout: DurationIR,\n onTimeout: z.union([z.literal('continue'), z.literal('exit'), z.array(NodeIR)]),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('branch'),\n cases: z.array(\n z.object({\n label: z.optional(z.string()),\n condition: ConditionIR,\n flow: z.array(NodeIR),\n })\n ),\n otherwise: z.optional(z.array(NodeIR)),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('filter'),\n condition: ConditionIR,\n reason: z.optional(z.string()),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('cohort'),\n key: z.optional(z.string()),\n arms: z.array(z.object({ name: z.string(), weight: z.number(), flow: z.array(NodeIR) })),\n }),\n z.object({ ...nodeBase, type: z.literal('exit'), reason: z.optional(z.string()) }),\n z.object({\n ...nodeBase,\n type: z.literal('send_event'),\n event: z.string(),\n payload: z.record(z.string(), PropValueIR),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('action'),\n action: z.string(),\n args: z.record(z.string(), PropValueIR),\n }),\n ])\n);\n\n/* ---------------------- Top-level definitions and bundle --------------------- */\n\nexport const GoalIR = z.object({\n condition: ConditionIR,\n within: z.optional(DurationIR),\n exitOnMatch: z.boolean(),\n});\nexport type GoalIR = z.infer<typeof GoalIR>;\n\n/**\n * Human-facing metadata: **excluded from contentHash** (see hash.ts).\n * Editing a name or description does not touch execution semantics, so it never\n * invalidates the version in-flight users are pinned to, nor shows up in plan.\n */\nexport const MetaIR = z.object({\n /**\n * Human label. The asset's `id` is its identity — a wire key that routing\n * tables and the entry ledger are built on — so it is not something to\n * rename for readability. This is: it is metadata, dropped before hashing,\n * and the studio shows it in place of the key wherever it is set.\n */\n name: z.optional(z.string()),\n description: z.optional(z.string()),\n loc: z.optional(SourceLocIR),\n});\nexport type MetaIR = z.infer<typeof MetaIR>;\n\nexport const WorkflowIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`workflow({ id })`): routing tables, the entry ledger and reporting are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n meta: z.optional(MetaIR),\n trigger: TriggerIR,\n goal: z.optional(GoalIR),\n exitWhen: z.optional(ConditionIR),\n flow: z.array(NodeIR),\n});\nexport type WorkflowIR = z.infer<typeof WorkflowIR>;\n\nexport const SegmentIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`segment({ id })`): segment references and the membership store are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n /** Provenance plus the human label — MetaIR, same as a workflow's. */\n meta: z.optional(MetaIR),\n condition: ConditionIR,\n});\nexport type SegmentIR = z.infer<typeof SegmentIR>;\n\n/** Template manifest entry: IR records only the reference; the body lives in the template system. */\nexport const TemplateIR = z.object({\n irVersion: z.literal(IR_VERSION),\n key: z.string(),\n channel: ChannelIR,\n});\nexport type TemplateIR = z.infer<typeof TemplateIR>;\n\n/**\n * Action manifest entry: IR records the name; the implementation solidifies\n * into the Worker bundle at deploy time, never into IR (custom code stays out\n * of the data plane by design).\n */\nexport const ActionIR = z.object({\n irVersion: z.literal(IR_VERSION),\n name: z.string(),\n});\nexport type ActionIR = z.infer<typeof ActionIR>;\n\n/**\n * Deployment unit: the complete snapshot one `deploy` produces (terraform\n * apply, same mental model). The server diffs each definition's contentHash to\n * decide publish / skip / retire.\n */\n/**\n * The subscription topics the contract declares, carried by the bundle so the engine validates\n * `/subscriptions` and the templates' topics against the deployed list, not the SDK's word.\n */\nexport const SubscriptionsIR = z.object({\n topics: z.array(z.string()),\n});\nexport type SubscriptionsIR = z.infer<typeof SubscriptionsIR>;\n\nexport const BundleIR = z.object({\n irVersion: z.literal(IR_VERSION),\n workflows: z.array(WorkflowIR),\n segments: z.array(SegmentIR),\n templates: z.array(TemplateIR),\n actions: z.array(ActionIR),\n /** Absent in a bundle compiled before topics existed: no topics. */\n subscriptions: z.optional(SubscriptionsIR),\n});\nexport type BundleIR = z.infer<typeof BundleIR>;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ir.mjs","names":[],"sources":["../src/ir.ts"],"sourcesContent":["import * as z from 'zod/mini';\n\n/**\n * IR (Intermediate Representation) — what the DSL compiles to: serializable\n * JSON. It is the three-way contract between the engine that interprets it,\n * the UI (react-flow) that renders it, and diff / audit tooling.\n *\n * Validation strategy: the zod/mini schemas are the authoritative definition.\n * TS types are either inferred from a schema or hand-written under the same\n * name (zod cannot infer recursive types; the hand-written type is anchored\n * with a ZodMiniType<T> annotation, so any drift fails to compile). IR crosses\n * process boundaries (database, network, UI), so runtime validation is a must.\n *\n * ## Versioning (two independent axes)\n *\n * 1. IR format version (irVersion): evolution of the IR schema itself. Adding\n * optional fields does not bump it; only structurally incompatible changes\n * do (changing a discriminant, dropping a field, altering semantics).\n * Readers dispatch on irVersion to the matching schema/migrator; writers\n * always write the current version.\n * 2. Content version (contentHash): a content-addressed id for a single\n * definition's **execution semantics** — the canonical-JSON hash after\n * metadata (meta / label / reason) is stripped, see hash.ts. It is used to:\n * - pin a version when a user enters, so an in-flight journey runs the\n * pinned version to completion (the default policy; compatible hot\n * updates are a future explicit-migration topic). Known exception:\n * segment conditions are resolved **by id at evaluation time** — a\n * segment redeploy changes behaviour of in-flight journeys that\n * reference it. Pinning covers the workflow's own structure; per-hash\n * segment snapshots are future work;\n * - diff on deploy: same hash means unchanged, skip publishing;\n * - audit: answer \"which version was this user on\" at any point.\n *\n * ## Node ids\n *\n * Derived from the structural path at compile time — stable and deterministic:\n * root level '0', '1', …; the j-th child of a branch's i-th case is\n * '{branchId}.c{i}.{j}', the default arm '{branchId}.o.{j}'; cohort arms use\n * the arm name '{cohortId}.{armName}.{j}'; a waitUntil timeout flow '{id}.t.{j}'.\n * Editing a node's parameters keeps its id; inserting a node shifts the ids of\n * its later siblings (a known trade-off — locating in-flight users relies on\n * the pinned version rather than cross-version id alignment, and cross-version\n * mapping is an explicit migrator's job).\n */\n\nexport const IR_VERSION = 2;\n\n/* -------------------------------- Base values ------------------------------- */\n\nexport const ScalarIR = z.union([z.string(), z.number(), z.boolean()]);\nexport type ScalarIR = z.infer<typeof ScalarIR>;\n\n/** Duration: `value` keeps the DSL literal ('23 hours', for display), `ms` is what the engine uses. */\n/*\n * TODO: `value` is hashed alongside `ms`, so '7 days' / '1 week' / '168 hours'\n * — identical to the engine, which only reads ms — produce three different\n * contentHashes. Rewording a duration therefore reports as a semantic change in\n * plan and repins in-flight users. Moving `value` out of the hash (it is a\n * display string) would fix it; deferred because it changes existing hashes.\n */\nexport const DurationIR = z.object({ value: z.string(), ms: z.number() });\nexport type DurationIR = z.infer<typeof DurationIR>;\n\n/** User-property reference — isomorphic to the DSL's UserPropertyRef once serialized (phantom type dropped). */\nexport const UserPropertyRefIR = z.object({\n type: z.literal('user_property'),\n path: z.string(),\n});\nexport type UserPropertyRefIR = z.infer<typeof UserPropertyRefIR>;\n\n/** A message prop / event payload value: either a literal or a user-property reference. */\nexport const PropValueIR = z.union([ScalarIR, UserPropertyRefIR]);\nexport type PropValueIR = z.infer<typeof PropValueIR>;\n\nexport const ChannelIR = z.enum(['email', 'sms', 'push', 'in_app', 'slack', 'discord', 'survey']);\nexport type ChannelIR = z.infer<typeof ChannelIR>;\n\nexport const WeekdayIR = z.enum(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);\nexport type WeekdayIR = z.infer<typeof WeekdayIR>;\n\n/* --------------------------------- Provenance -------------------------------- */\n\n/**\n * Source location of the builder call that produced a definition — the IR's\n * source map. Lives under `meta`, so it is **excluded from contentHash**\n * (see hash.ts): provenance never affects execution identity or plan.\n * `file` is cwd-relative when compiled under Node, a URL under Vite dev.\n */\nexport const SourceLocIR = z.object({\n file: z.string(),\n line: z.number(),\n column: z.number(),\n});\nexport type SourceLocIR = z.infer<typeof SourceLocIR>;\n\n/** Node-level metadata: provenance only, for now. Excluded from contentHash. */\nexport const NodeMetaIR = z.object({\n loc: z.optional(SourceLocIR),\n});\nexport type NodeMetaIR = z.infer<typeof NodeMetaIR>;\n\n/* -------------------------------- Conditions -------------------------------- */\n\nexport const PropertyOperatorIR = z.enum([\n 'eq',\n 'ne',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'between',\n 'not_between',\n 'in_array',\n 'not_in_array',\n 'exists',\n 'not_exists',\n 'contains',\n 'not_contains',\n]);\nexport type PropertyOperatorIR = z.infer<typeof PropertyOperatorIR>;\n\n/**\n * Condition expression tree. The shape of value/values follows `op`:\n * eq/ne/gt/gte/lt/lte/contains/not_contains → value; in_array/not_in_array →\n * values; between/not_between → values = [min, max]; exists/not_exists → neither.\n *\n * `payload` predicates test the payload of an event (dotted paths reach nested\n * fields). They are only valid inside a `where` subtree — `performed.where`\n * and the event trigger's `where` — where an event row is in scope; the\n * compiler (compileBundle) and the evaluator both enforce the placement.\n * TODO: runtime refinement narrowing by op.\n */\n/**\n * Provenance rides on conditions too, so the studio can point at the exact\n * `eq(...)` / `and(...)` that produced a node — same rule as nodes: `meta` is\n * dropped before hashing, so carrying it never affects execution identity.\n */\ninterface ConditionBaseIR {\n meta?: NodeMetaIR | undefined;\n}\n\nexport type ConditionIR =\n | (ConditionBaseIR & { type: 'and'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'or'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'not'; condition: ConditionIR })\n /** Reference a segment by id (defined in SegmentIR, versioned separately). */\n | (ConditionBaseIR & { type: 'segment'; segment: string })\n | (ConditionBaseIR & {\n type: 'property';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n /** Event payload predicate; `path` is dotted for nested fields ('tags.utm_source'). */\n type: 'payload';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n type: 'performed';\n event: string;\n /** Payload filter: only events matching it count (a payload-only subtree). */\n where?: ConditionIR | undefined;\n within?: DurationIR | undefined;\n count?: number | undefined;\n })\n /**\n * The user can be reached on the channel right now: for push, the host's\n * device directory lists at least one device; for the others, the channel's\n * recipient property (email, phone, …) is set.\n */\n | (ConditionBaseIR & { type: 'reachable'; channel: ChannelIR });\n\nconst conditionBase = { meta: z.optional(NodeMetaIR) };\n\nexport const ConditionIR: z.ZodMiniType<ConditionIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({ ...conditionBase, type: z.literal('and'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('or'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('not'), condition: ConditionIR }),\n z.object({ ...conditionBase, type: z.literal('segment'), segment: z.string() }),\n z.object({\n ...conditionBase,\n type: z.literal('property'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('payload'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('performed'),\n event: z.string(),\n where: z.optional(ConditionIR),\n within: z.optional(DurationIR),\n count: z.optional(z.number()),\n }),\n z.object({ ...conditionBase, type: z.literal('reachable'), channel: ChannelIR }),\n ])\n);\n\n/* --------------------------------- Triggers --------------------------------- */\n\n/**\n * Trigger shapes. The event trigger has two independent gates: `where` tests\n * the triggering event's payload (payload-only subtree, evaluated against the\n * incoming event before anything else), `filter` tests the user's profile.\n */\nexport const TriggerIR = z.discriminatedUnion('type', [\n z.object({\n type: z.literal('event'),\n event: z.string(),\n where: z.optional(ConditionIR),\n filter: z.optional(ConditionIR),\n visitors: z.optional(z.literal(true)),\n }),\n z.object({ type: z.literal('segment'), segment: z.string() }),\n z.object({ type: z.literal('date'), at: z.string() }),\n z.object({ type: z.literal('webhook') }),\n]);\nexport type TriggerIR = z.infer<typeof TriggerIR>;\n\n/* ----------------------------------- Nodes ---------------------------------- */\n\ninterface NodeBaseIR {\n /** Stable id derived from the structural path (rules in the file header). */\n id: string;\n /** Optional node name: UI title / observability handle (branch's optional first argument lands here). */\n label?: string | undefined;\n /** Metadata (provenance); excluded from contentHash. */\n meta?: NodeMetaIR | undefined;\n}\n\nexport type NodeIR =\n | (NodeBaseIR & {\n type: 'message';\n channel: ChannelIR;\n template: string;\n props: Record<string, PropValueIR>;\n /**\n * Push only: which of the user's devices get a copy. Absent means every\n * device the directory lists; 'latest' only the most recently registered.\n */\n devices?: 'latest' | undefined;\n /**\n * Email and push: the template's subscription topic, or null for a transactional message\n * (sent whatever the user opted out of). Absent for other channels, and in IR compiled\n * before topics existed: such a step checks the channel-level opt-out only, as it always did.\n */\n topic?: string | null | undefined;\n })\n | (NodeBaseIR & { type: 'delay'; duration: DurationIR })\n | (NodeBaseIR & { type: 'random_delay'; min: DurationIR; max: DurationIR })\n | (NodeBaseIR & {\n type: 'time_window';\n days: WeekdayIR[];\n between: [string, string];\n tz: string;\n })\n | (NodeBaseIR & {\n type: 'wait_until';\n condition: ConditionIR;\n timeout: DurationIR;\n onTimeout: 'continue' | 'exit' | NodeIR[];\n })\n | (NodeBaseIR & {\n type: 'branch';\n cases: { label?: string | undefined; condition: ConditionIR; flow: NodeIR[] }[];\n /** Default arm (the DSL's bare tail argument). Absent = fall through to the main line. */\n otherwise?: NodeIR[] | undefined;\n })\n | (NodeBaseIR & {\n type: 'filter';\n /** Audit-log line for the users this gate drops. Metadata: excluded from contentHash. */\n reason?: string | undefined;\n condition: ConditionIR;\n })\n | (NodeBaseIR & {\n type: 'cohort';\n /**\n * Explicit experiment key: bucketing hashes `userId:key` instead of the\n * structural node id, so the assignment survives node insertion/moves and\n * the experiment keeps one identity across workflow versions. Absent →\n * the node id is used (fine for one-off splits).\n */\n key?: string | undefined;\n /**\n * Arms in authoring order — the order the interpreter accumulates weights\n * in, so it decides which bucket range each arm owns. The DSL keeps that\n * promise by rejecting integer-like arm names, which JavaScript would\n * hoist to the front of the object in numeric order. Weights sum to 100.\n */\n arms: { name: string; weight: number; flow: NodeIR[] }[];\n })\n | (NodeBaseIR & {\n /** `reason` is the audit-log line. Metadata: excluded from contentHash. */\n type: 'exit';\n reason?: string | undefined;\n })\n | (NodeBaseIR & { type: 'send_event'; event: string; payload: Record<string, PropValueIR> })\n | (NodeBaseIR & {\n type: 'action';\n /** Registry name of the custom action; the code itself lives in the deployed Worker bundle. */\n action: string;\n /** Argument values; user_property references are resolved at run time, like message props. */\n args: Record<string, PropValueIR>;\n });\n\nconst nodeBase = { id: z.string(), label: z.optional(z.string()), meta: z.optional(NodeMetaIR) };\n\nexport const NodeIR: z.ZodMiniType<NodeIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({\n ...nodeBase,\n type: z.literal('message'),\n channel: ChannelIR,\n template: z.string(),\n props: z.record(z.string(), PropValueIR),\n devices: z.optional(z.literal('latest')),\n topic: z.optional(z.nullable(z.string())),\n }),\n z.object({ ...nodeBase, type: z.literal('delay'), duration: DurationIR }),\n z.object({ ...nodeBase, type: z.literal('random_delay'), min: DurationIR, max: DurationIR }),\n z.object({\n ...nodeBase,\n type: z.literal('time_window'),\n days: z.array(WeekdayIR),\n between: z.tuple([z.string(), z.string()]),\n tz: z.string(),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('wait_until'),\n condition: ConditionIR,\n timeout: DurationIR,\n onTimeout: z.union([z.literal('continue'), z.literal('exit'), z.array(NodeIR)]),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('branch'),\n cases: z.array(\n z.object({\n label: z.optional(z.string()),\n condition: ConditionIR,\n flow: z.array(NodeIR),\n })\n ),\n otherwise: z.optional(z.array(NodeIR)),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('filter'),\n condition: ConditionIR,\n reason: z.optional(z.string()),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('cohort'),\n key: z.optional(z.string()),\n arms: z.array(z.object({ name: z.string(), weight: z.number(), flow: z.array(NodeIR) })),\n }),\n z.object({ ...nodeBase, type: z.literal('exit'), reason: z.optional(z.string()) }),\n z.object({\n ...nodeBase,\n type: z.literal('send_event'),\n event: z.string(),\n payload: z.record(z.string(), PropValueIR),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('action'),\n action: z.string(),\n args: z.record(z.string(), PropValueIR),\n }),\n ])\n);\n\n/* ---------------------- Top-level definitions and bundle --------------------- */\n\nexport const GoalIR = z.object({\n condition: ConditionIR,\n within: z.optional(DurationIR),\n exitOnMatch: z.boolean(),\n});\nexport type GoalIR = z.infer<typeof GoalIR>;\n\n/**\n * Human-facing metadata: **excluded from contentHash** (see hash.ts).\n * Editing a name or description does not touch execution semantics, so it never\n * invalidates the version in-flight users are pinned to, nor shows up in plan.\n */\nexport const MetaIR = z.object({\n /**\n * Human label. The asset's `id` is its identity — a wire key that routing\n * tables and the entry ledger are built on — so it is not something to\n * rename for readability. This is: it is metadata, dropped before hashing,\n * and the studio shows it in place of the key wherever it is set.\n */\n name: z.optional(z.string()),\n description: z.optional(z.string()),\n loc: z.optional(SourceLocIR),\n});\nexport type MetaIR = z.infer<typeof MetaIR>;\n\nexport const WorkflowIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`workflow({ id })`): routing tables, the entry ledger and reporting are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n meta: z.optional(MetaIR),\n trigger: TriggerIR,\n goal: z.optional(GoalIR),\n exitWhen: z.optional(ConditionIR),\n flow: z.array(NodeIR),\n});\nexport type WorkflowIR = z.infer<typeof WorkflowIR>;\n\nexport const SegmentIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`segment({ id })`): segment references and the membership store are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n /** Provenance plus the human label — MetaIR, same as a workflow's. */\n meta: z.optional(MetaIR),\n condition: ConditionIR,\n});\nexport type SegmentIR = z.infer<typeof SegmentIR>;\n\n/** Template manifest entry: IR records only the reference; the body lives in the template system. */\nexport const TemplateIR = z.object({\n irVersion: z.literal(IR_VERSION),\n key: z.string(),\n channel: ChannelIR,\n});\nexport type TemplateIR = z.infer<typeof TemplateIR>;\n\n/**\n * Action manifest entry: IR records the name; the implementation solidifies\n * into the Worker bundle at deploy time, never into IR (custom code stays out\n * of the data plane by design).\n */\nexport const ActionIR = z.object({\n irVersion: z.literal(IR_VERSION),\n name: z.string(),\n});\nexport type ActionIR = z.infer<typeof ActionIR>;\n\n/**\n * Deployment unit: the complete snapshot one `deploy` produces (terraform\n * apply, same mental model). The server diffs each definition's contentHash to\n * decide publish / skip / retire.\n */\n/**\n * The subscription topics the contract declares, carried by the bundle so the engine validates\n * `/subscriptions` and the templates' topics against the deployed list, not the SDK's word.\n */\nexport const SubscriptionsIR = z.object({\n topics: z.array(z.string()),\n});\nexport type SubscriptionsIR = z.infer<typeof SubscriptionsIR>;\n\nexport const BundleIR = z.object({\n irVersion: z.literal(IR_VERSION),\n workflows: z.array(WorkflowIR),\n segments: z.array(SegmentIR),\n templates: z.array(TemplateIR),\n actions: z.array(ActionIR),\n /** Absent in a bundle compiled before topics existed: no topics. */\n subscriptions: z.optional(SubscriptionsIR),\n});\nexport type BundleIR = z.infer<typeof BundleIR>;\n"],"mappings":"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"}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_engine_recipient = require("../engine/recipient.cjs");
|
|
3
|
+
require("./facts.cjs");
|
|
4
|
+
// src/store/condition-sql.ts
|
|
5
|
+
const UNKNOWN = "NULL::boolean";
|
|
6
|
+
/** `to_timestamp`, as every other read of `ts` in the store writes it. */
|
|
7
|
+
const timestamp = (param) => `to_timestamp(${param}::double precision / 1000)`;
|
|
8
|
+
function conditionToSql(condition, context) {
|
|
9
|
+
if (JSON.stringify(condition).includes("\\u0000")) return UNKNOWN;
|
|
10
|
+
return compile(condition, context, []);
|
|
11
|
+
}
|
|
12
|
+
function compile(condition, context, enclosing) {
|
|
13
|
+
switch (condition.type) {
|
|
14
|
+
case "and": return join(condition.conditions.map((child) => compile(child, context, enclosing)), "AND", "TRUE");
|
|
15
|
+
case "or": return join(condition.conditions.map((child) => compile(child, context, enclosing)), "OR", "FALSE");
|
|
16
|
+
case "not": return `(NOT ${compile(condition.condition, context, enclosing)})`;
|
|
17
|
+
case "segment": {
|
|
18
|
+
const definition = context.segments.get(condition.segment);
|
|
19
|
+
if (definition === void 0 || enclosing.includes(condition.segment)) return UNKNOWN;
|
|
20
|
+
return compile(definition, context, [...enclosing, condition.segment]);
|
|
21
|
+
}
|
|
22
|
+
case "performed": return performed(condition, context);
|
|
23
|
+
case "property": return compare(condition, once(() => profileScalar(condition.path, context)), context);
|
|
24
|
+
case "reachable": return reachable(condition.channel, context);
|
|
25
|
+
case "payload": return UNKNOWN;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function once(make) {
|
|
29
|
+
let made;
|
|
30
|
+
return () => made ??= make();
|
|
31
|
+
}
|
|
32
|
+
function join(parts, operator, empty) {
|
|
33
|
+
return parts.length === 0 ? empty : `(${parts.join(` ${operator} `)})`;
|
|
34
|
+
}
|
|
35
|
+
/** Mirrors `JourneyFactSource.countEvents`: the window's bounds are inclusive at both ends. */
|
|
36
|
+
function performed(condition, context) {
|
|
37
|
+
const atLeast = Math.ceil(condition.count ?? 1);
|
|
38
|
+
if (!Number.isSafeInteger(atLeast)) return UNKNOWN;
|
|
39
|
+
if (atLeast <= 0) return "TRUE";
|
|
40
|
+
const since = condition.within === void 0 ? void 0 : context.nowMs - condition.within.ms;
|
|
41
|
+
if (since !== void 0 && !Number.isFinite(since)) return UNKNOWN;
|
|
42
|
+
let events = `SELECT 1 FROM ${context.tables.event} ev WHERE ev.user_id = ${context.user} AND ev.name = ${context.bind(condition.event)}::text`;
|
|
43
|
+
if (since !== void 0) events += ` AND ev.ts >= ${timestamp(context.bind(since))}`;
|
|
44
|
+
if (condition.where === void 0) return reaches(events, atLeast);
|
|
45
|
+
const where = whereToSql(condition.where, context);
|
|
46
|
+
return `(CASE WHEN ${reaches(`${events} AND (${where}) IS TRUE`, atLeast)} THEN TRUE WHEN ${reaches(`${events} AND (${where}) IS NOT FALSE`, atLeast)} THEN ${UNKNOWN} ELSE FALSE END)`;
|
|
47
|
+
}
|
|
48
|
+
/** Whether a query yields `atLeast` rows, stopping there: the (user_id, name, ts) index serves it. */
|
|
49
|
+
function reaches(query, atLeast) {
|
|
50
|
+
return atLeast === 1 ? `EXISTS (${query})` : `((SELECT count(*) FROM (${query} LIMIT ${String(atLeast)}) capped) >= ${String(atLeast)})`;
|
|
51
|
+
}
|
|
52
|
+
/** Mirrors `matchesWhere`, over the event row `ev`. */
|
|
53
|
+
function whereToSql(where, context) {
|
|
54
|
+
switch (where.type) {
|
|
55
|
+
case "and": return join(where.conditions.map((child) => whereToSql(child, context)), "AND", "TRUE");
|
|
56
|
+
case "or": return join(where.conditions.map((child) => whereToSql(child, context)), "OR", "FALSE");
|
|
57
|
+
case "not": return `(NOT ${whereToSql(where.condition, context)})`;
|
|
58
|
+
case "payload": {
|
|
59
|
+
const keys = where.path.split(".");
|
|
60
|
+
if (!keys.every(isPlainKey)) return UNKNOWN;
|
|
61
|
+
const raw = once(() => keys.reduce((json, key) => `${json} -> ${context.bind(key)}::text`, "ev.payload"));
|
|
62
|
+
const present = () => `(jsonb_typeof(${raw()}) IS DISTINCT FROM 'null' AND ${raw()} IS NOT NULL)`;
|
|
63
|
+
if (where.op === "exists") return present();
|
|
64
|
+
if (where.op === "not_exists") return `(NOT ${present()})`;
|
|
65
|
+
return compare(where, once(() => scalarOf(raw())), context);
|
|
66
|
+
}
|
|
67
|
+
default: return UNKNOWN;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A key `valueAtPath` finds in plain JSON objects only. It indexes with `current[key]`, so a
|
|
72
|
+
* number reaches into an array, `length` reads an array's size and `toString` finds the prototype,
|
|
73
|
+
* none of which `->` does.
|
|
74
|
+
*/
|
|
75
|
+
function isPlainKey(key) {
|
|
76
|
+
return !/^\d+$/.test(key) && key !== "length" && !(key in Object.prototype);
|
|
77
|
+
}
|
|
78
|
+
/** The value if it is a JSON scalar, SQL NULL otherwise: a non-scalar reads as absent. */
|
|
79
|
+
function scalarOf(json) {
|
|
80
|
+
return `(CASE WHEN jsonb_typeof(${json}) IN ('string', 'number', 'boolean') THEN ${json} END)`;
|
|
81
|
+
}
|
|
82
|
+
/** Mirrors `JourneyFactSource.getProperty`. */
|
|
83
|
+
function profileScalar(path, context) {
|
|
84
|
+
if (path === "userId") return `to_jsonb(${context.user})`;
|
|
85
|
+
return scalarOf(`${context.props} -> ${context.bind(path)}::text`);
|
|
86
|
+
}
|
|
87
|
+
function reachable(channel, context) {
|
|
88
|
+
const suppressed = `EXISTS (SELECT 1 FROM ${context.tables.suppression} s WHERE s.user_id = ${context.user} AND s.channel = ${context.bind(channel)}::text AND s.topic IS NULL)`;
|
|
89
|
+
let address;
|
|
90
|
+
if (channel === "push") address = `EXISTS (SELECT 1 FROM ${context.tables.device} d WHERE d.user_id = ${context.user})`;
|
|
91
|
+
else {
|
|
92
|
+
const path = require_engine_recipient.RECIPIENT_PROPERTY[channel];
|
|
93
|
+
address = path === void 0 ? "TRUE" : `${profileScalar(path, context)} IS NOT NULL`;
|
|
94
|
+
}
|
|
95
|
+
return `(NOT ${suppressed} AND ${address})`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Mirrors `compareProperty`. `actual` yields a jsonb scalar, or SQL NULL for "undefined". It is
|
|
99
|
+
* asked for only by a branch that uses it: Postgres cannot type a parameter no statement mentions,
|
|
100
|
+
* so an operator that needs no value (`between` without a range) must not bind one.
|
|
101
|
+
*/
|
|
102
|
+
function compare(condition, actual, context) {
|
|
103
|
+
const { value, values } = condition;
|
|
104
|
+
const json = (scalar) => `${context.bind(JSON.stringify(scalar))}::text::jsonb`;
|
|
105
|
+
const isIn = () => (values ?? []).length === 0 ? "FALSE" : `COALESCE(${actual()} IN (${(values ?? []).map(json).join(", ")}), FALSE)`;
|
|
106
|
+
const isBetween = () => {
|
|
107
|
+
if (values === void 0 || values.length < 2) return "FALSE";
|
|
108
|
+
const [min, max] = values;
|
|
109
|
+
return `(${order(actual, ">=", min, context)} AND ${order(actual, "<=", max, context)})`;
|
|
110
|
+
};
|
|
111
|
+
const contains = () => typeof value === "string" ? `COALESCE(jsonb_typeof(${actual()}) = 'string' AND strpos(${actual()} #>> '{}', ${context.bind(value)}::text) > 0, FALSE)` : "FALSE";
|
|
112
|
+
switch (condition.op) {
|
|
113
|
+
case "exists": return `(${actual()} IS NOT NULL)`;
|
|
114
|
+
case "not_exists": return `(${actual()} IS NULL)`;
|
|
115
|
+
case "eq": return value === void 0 ? `(${actual()} IS NULL)` : `COALESCE(${actual()} = ${json(value)}, FALSE)`;
|
|
116
|
+
case "ne": return value === void 0 ? `(${actual()} IS NOT NULL)` : `(${actual()} IS DISTINCT FROM ${json(value)})`;
|
|
117
|
+
case "gt": return order(actual, ">", value, context);
|
|
118
|
+
case "gte": return order(actual, ">=", value, context);
|
|
119
|
+
case "lt": return order(actual, "<", value, context);
|
|
120
|
+
case "lte": return order(actual, "<=", value, context);
|
|
121
|
+
case "between": return isBetween();
|
|
122
|
+
case "not_between": return `(NOT ${isBetween()})`;
|
|
123
|
+
case "in_array": return isIn();
|
|
124
|
+
case "not_in_array": return `(NOT ${isIn()})`;
|
|
125
|
+
case "contains": return contains();
|
|
126
|
+
case "not_contains": return `(NOT ${contains()})`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* An ordering of two values of one type; across types JavaScript coerces, so that is the
|
|
131
|
+
* evaluator's to answer. Strings compare by code point, what `COLLATE "C"` does on UTF-8: JavaScript
|
|
132
|
+
* compares UTF-16 units, which differs only between an astral character and U+E000 to U+FFFF.
|
|
133
|
+
*/
|
|
134
|
+
function order(scalar, operator, value, context) {
|
|
135
|
+
if (value === void 0) return "FALSE";
|
|
136
|
+
const actual = scalar();
|
|
137
|
+
let same = UNKNOWN;
|
|
138
|
+
if (typeof value === "number" && Number.isFinite(value)) same = `(CASE WHEN jsonb_typeof(${actual}) = 'number' THEN (${actual})::numeric ${operator} ${`${context.bind(String(value))}::text::numeric`} END)`;
|
|
139
|
+
else if (typeof value === "string") same = `(CASE WHEN jsonb_typeof(${actual}) = 'string' THEN (${actual} #>> '{}') COLLATE "C" ${operator} ${`${context.bind(value)}::text`} END)`;
|
|
140
|
+
return `(CASE WHEN ${actual} IS NULL THEN FALSE ELSE ${same} END)`;
|
|
141
|
+
}
|
|
142
|
+
exports.conditionToSql = conditionToSql;
|
|
143
|
+
|
|
144
|
+
//# sourceMappingURL=condition-sql.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"condition-sql.cjs","names":["RECIPIENT_PROPERTY"],"sources":["../../src/store/condition-sql.ts"],"sourcesContent":["import { RECIPIENT_PROPERTY } from '../engine/recipient';\nimport type { ConditionIR, PropertyOperatorIR, ScalarIR } from '../ir';\nimport { USER_ID_PROPERTY } from './facts';\n\n/**\n * A condition as one SQL boolean over a whole set of users, for the segment sweep: where\n * `engine/condition.ts` asks about one user in several round trips, this asks about all of them\n * in one statement.\n *\n * `engine/condition.ts` stays the authority, and this only narrows down who it has to look at.\n * So the result is three-valued: TRUE and FALSE state what the evaluator would answer, and NULL\n * means \"ask the evaluator\". SQL's AND, OR and NOT carry a NULL exactly as far as it can affect\n * the outcome, so a leaf that cannot match JavaScript's semantics returns NULL and nothing above\n * it has to know. That covers what SQL has no clean form for: a comparison across types (`'5' > 3`\n * coerces in JavaScript), a segment that is unknown or reaches itself (the evaluator throws, and\n * its error is the one to report), hand-built IR.\n *\n * Injection: nothing from the condition is ever written into the statement. Every name, path and\n * value goes through `bind`, as does the clock; the only other text is this file's own SQL, the\n * store's table names and a count checked to be an integer. Keep it that way: a segment id or a\n * property path is data, however trusted the bundle that carries it.\n */\n\nexport interface ConditionSqlContext {\n /** The qualified tables the condition reads. */\n tables: { event: string; suppression: string; device: string };\n /** The column holding the user id. */\n user: string;\n /** The column holding the user's profile props, NULL when there is no profile. */\n props: string;\n nowMs: number;\n /** Every deployed segment, for `segment(...)` references. */\n segments: ReadonlyMap<string, ConditionIR>;\n /** Registers a parameter and returns its placeholder (`$3`). */\n bind(value: unknown): string;\n}\n\nconst UNKNOWN = 'NULL::boolean';\n\n/** `to_timestamp`, as every other read of `ts` in the store writes it. */\nconst timestamp = (param: string) => `to_timestamp(${param}::double precision / 1000)`;\n\nexport function conditionToSql(condition: ConditionIR, context: ConditionSqlContext): string {\n // Postgres refuses NUL in text and jsonb, and one bad parameter would fail the statement for\n // every segment on the page. JSON escapes it, so this sees it wherever the tree holds it.\n if (JSON.stringify(condition).includes('\\\\u0000')) return UNKNOWN;\n return compile(condition, context, []);\n}\n\nfunction compile(\n condition: ConditionIR,\n context: ConditionSqlContext,\n enclosing: readonly string[]\n): string {\n switch (condition.type) {\n case 'and':\n return join(\n condition.conditions.map((child) => compile(child, context, enclosing)),\n 'AND',\n 'TRUE'\n );\n case 'or':\n return join(\n condition.conditions.map((child) => compile(child, context, enclosing)),\n 'OR',\n 'FALSE'\n );\n case 'not':\n return `(NOT ${compile(condition.condition, context, enclosing)})`;\n case 'segment': {\n const definition = context.segments.get(condition.segment);\n if (definition === undefined || enclosing.includes(condition.segment)) return UNKNOWN;\n return compile(definition, context, [...enclosing, condition.segment]);\n }\n case 'performed':\n return performed(condition, context);\n case 'property':\n return compare(\n condition,\n once(() => profileScalar(condition.path, context)),\n context\n );\n case 'reachable':\n return reachable(condition.channel, context);\n case 'payload':\n // The evaluator rejects a payload condition outside a where clause\n return UNKNOWN;\n }\n}\n\nfunction once(make: () => string): () => string {\n let made: string | undefined;\n return () => (made ??= make());\n}\n\nfunction join(parts: string[], operator: 'AND' | 'OR', empty: string): string {\n return parts.length === 0 ? empty : `(${parts.join(` ${operator} `)})`;\n}\n\n/** Mirrors `JourneyFactSource.countEvents`: the window's bounds are inclusive at both ends. */\nfunction performed(\n condition: Extract<ConditionIR, { type: 'performed' }>,\n context: ConditionSqlContext\n): string {\n const atLeast = Math.ceil(condition.count ?? 1);\n if (!Number.isSafeInteger(atLeast)) return UNKNOWN;\n if (atLeast <= 0) return 'TRUE'; // Every count is at least zero\n const since = condition.within === undefined ? undefined : context.nowMs - condition.within.ms;\n // Before anything is bound: to_timestamp would fail the whole statement on it\n if (since !== undefined && !Number.isFinite(since)) return UNKNOWN;\n let events =\n `SELECT 1 FROM ${context.tables.event} ev ` +\n `WHERE ev.user_id = ${context.user} AND ev.name = ${context.bind(condition.event)}::text`;\n if (since !== undefined) events += ` AND ev.ts >= ${timestamp(context.bind(since))}`;\n if (condition.where === undefined) return reaches(events, atLeast);\n const where = whereToSql(condition.where, context);\n // An event the where clause cannot decide may or may not count\n const certain = reaches(`${events} AND (${where}) IS TRUE`, atLeast);\n const possible = reaches(`${events} AND (${where}) IS NOT FALSE`, atLeast);\n return `(CASE WHEN ${certain} THEN TRUE WHEN ${possible} THEN ${UNKNOWN} ELSE FALSE END)`;\n}\n\n/** Whether a query yields `atLeast` rows, stopping there: the (user_id, name, ts) index serves it. */\nfunction reaches(query: string, atLeast: number): string {\n return atLeast === 1\n ? `EXISTS (${query})`\n : `((SELECT count(*) FROM (${query} LIMIT ${String(atLeast)}) capped) >= ${String(atLeast)})`;\n}\n\n/** Mirrors `matchesWhere`, over the event row `ev`. */\nfunction whereToSql(where: ConditionIR, context: ConditionSqlContext): string {\n switch (where.type) {\n case 'and':\n return join(\n where.conditions.map((child) => whereToSql(child, context)),\n 'AND',\n 'TRUE'\n );\n case 'or':\n return join(\n where.conditions.map((child) => whereToSql(child, context)),\n 'OR',\n 'FALSE'\n );\n case 'not':\n return `(NOT ${whereToSql(where.condition, context)})`;\n case 'payload': {\n const keys = where.path.split('.');\n if (!keys.every(isPlainKey)) return UNKNOWN;\n const raw = once(() =>\n keys.reduce((json, key) => `${json} -> ${context.bind(key)}::text`, 'ev.payload')\n );\n // Unlike a comparison, exists sees objects and arrays too; only JSON null reads as missing\n const present = () =>\n `(jsonb_typeof(${raw()}) IS DISTINCT FROM 'null' AND ${raw()} IS NOT NULL)`;\n if (where.op === 'exists') return present();\n if (where.op === 'not_exists') return `(NOT ${present()})`;\n return compare(\n where,\n once(() => scalarOf(raw())),\n context\n );\n }\n default:\n // Compile rejects these, and the evaluator throws on hand-built IR\n return UNKNOWN;\n }\n}\n\n/**\n * A key `valueAtPath` finds in plain JSON objects only. It indexes with `current[key]`, so a\n * number reaches into an array, `length` reads an array's size and `toString` finds the prototype,\n * none of which `->` does.\n */\nfunction isPlainKey(key: string): boolean {\n return !/^\\d+$/.test(key) && key !== 'length' && !(key in Object.prototype);\n}\n\n/** The value if it is a JSON scalar, SQL NULL otherwise: a non-scalar reads as absent. */\nfunction scalarOf(json: string): string {\n return `(CASE WHEN jsonb_typeof(${json}) IN ('string', 'number', 'boolean') THEN ${json} END)`;\n}\n\n/** Mirrors `JourneyFactSource.getProperty`. */\nfunction profileScalar(path: string, context: ConditionSqlContext): string {\n if (path === USER_ID_PROPERTY) return `to_jsonb(${context.user})`;\n return scalarOf(`${context.props} -> ${context.bind(path)}::text`);\n}\n\nfunction reachable(\n channel: Extract<ConditionIR, { type: 'reachable' }>['channel'],\n context: ConditionSqlContext\n): string {\n // The channel-wide opt-out only, as in the evaluator: its row has no topic\n const suppressed =\n `EXISTS (SELECT 1 FROM ${context.tables.suppression} s ` +\n `WHERE s.user_id = ${context.user} AND s.channel = ${context.bind(channel)}::text AND s.topic IS NULL)`;\n let address: string;\n if (channel === 'push') {\n address = `EXISTS (SELECT 1 FROM ${context.tables.device} d WHERE d.user_id = ${context.user})`;\n } else {\n const path = RECIPIENT_PROPERTY[channel];\n address = path === undefined ? 'TRUE' : `${profileScalar(path, context)} IS NOT NULL`;\n }\n return `(NOT ${suppressed} AND ${address})`;\n}\n\n/**\n * Mirrors `compareProperty`. `actual` yields a jsonb scalar, or SQL NULL for \"undefined\". It is\n * asked for only by a branch that uses it: Postgres cannot type a parameter no statement mentions,\n * so an operator that needs no value (`between` without a range) must not bind one.\n */\nfunction compare(\n condition: {\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n },\n actual: () => string,\n context: ConditionSqlContext\n): string {\n const { value, values } = condition;\n const json = (scalar: ScalarIR) => `${context.bind(JSON.stringify(scalar))}::text::jsonb`;\n const isIn = () =>\n (values ?? []).length === 0\n ? 'FALSE'\n : `COALESCE(${actual()} IN (${(values ?? []).map(json).join(', ')}), FALSE)`;\n const isBetween = () => {\n if (values === undefined || values.length < 2) return 'FALSE';\n const [min, max] = values as [ScalarIR, ScalarIR, ...ScalarIR[]];\n return `(${order(actual, '>=', min, context)} AND ${order(actual, '<=', max, context)})`;\n };\n const contains = () =>\n typeof value === 'string'\n ? `COALESCE(jsonb_typeof(${actual()}) = 'string' AND strpos(${actual()} #>> '{}', ${context.bind(value)}::text) > 0, FALSE)`\n : 'FALSE';\n switch (condition.op) {\n case 'exists':\n return `(${actual()} IS NOT NULL)`;\n case 'not_exists':\n return `(${actual()} IS NULL)`;\n case 'eq':\n return value === undefined\n ? `(${actual()} IS NULL)`\n : `COALESCE(${actual()} = ${json(value)}, FALSE)`;\n case 'ne':\n return value === undefined\n ? `(${actual()} IS NOT NULL)`\n : `(${actual()} IS DISTINCT FROM ${json(value)})`;\n case 'gt':\n return order(actual, '>', value, context);\n case 'gte':\n return order(actual, '>=', value, context);\n case 'lt':\n return order(actual, '<', value, context);\n case 'lte':\n return order(actual, '<=', value, context);\n case 'between':\n return isBetween();\n case 'not_between':\n return `(NOT ${isBetween()})`;\n case 'in_array':\n return isIn();\n case 'not_in_array':\n return `(NOT ${isIn()})`;\n case 'contains':\n return contains();\n case 'not_contains':\n return `(NOT ${contains()})`;\n }\n}\n\n/**\n * An ordering of two values of one type; across types JavaScript coerces, so that is the\n * evaluator's to answer. Strings compare by code point, what `COLLATE \"C\"` does on UTF-8: JavaScript\n * compares UTF-16 units, which differs only between an astral character and U+E000 to U+FFFF.\n */\nfunction order(\n scalar: () => string,\n operator: '>' | '>=' | '<' | '<=',\n value: ScalarIR | undefined,\n context: ConditionSqlContext\n): string {\n if (value === undefined) return 'FALSE';\n const actual = scalar();\n let same = UNKNOWN;\n if (typeof value === 'number' && Number.isFinite(value)) {\n const bound = `${context.bind(String(value))}::text::numeric`;\n same = `(CASE WHEN jsonb_typeof(${actual}) = 'number' THEN (${actual})::numeric ${operator} ${bound} END)`;\n } else if (typeof value === 'string') {\n const bound = `${context.bind(value)}::text`;\n same = `(CASE WHEN jsonb_typeof(${actual}) = 'string' THEN (${actual} #>> '{}') COLLATE \"C\" ${operator} ${bound} END)`;\n }\n return `(CASE WHEN ${actual} IS NULL THEN FALSE ELSE ${same} END)`;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ConditionIR } from "../ir.cjs";
|
|
2
|
+
/**
|
|
3
|
+
* A condition as one SQL boolean over a whole set of users, for the segment sweep: where
|
|
4
|
+
* `engine/condition.ts` asks about one user in several round trips, this asks about all of them
|
|
5
|
+
* in one statement.
|
|
6
|
+
*
|
|
7
|
+
* `engine/condition.ts` stays the authority, and this only narrows down who it has to look at.
|
|
8
|
+
* So the result is three-valued: TRUE and FALSE state what the evaluator would answer, and NULL
|
|
9
|
+
* means "ask the evaluator". SQL's AND, OR and NOT carry a NULL exactly as far as it can affect
|
|
10
|
+
* the outcome, so a leaf that cannot match JavaScript's semantics returns NULL and nothing above
|
|
11
|
+
* it has to know. That covers what SQL has no clean form for: a comparison across types (`'5' > 3`
|
|
12
|
+
* coerces in JavaScript), a segment that is unknown or reaches itself (the evaluator throws, and
|
|
13
|
+
* its error is the one to report), hand-built IR.
|
|
14
|
+
*
|
|
15
|
+
* Injection: nothing from the condition is ever written into the statement. Every name, path and
|
|
16
|
+
* value goes through `bind`, as does the clock; the only other text is this file's own SQL, the
|
|
17
|
+
* store's table names and a count checked to be an integer. Keep it that way: a segment id or a
|
|
18
|
+
* property path is data, however trusted the bundle that carries it.
|
|
19
|
+
*/
|
|
20
|
+
export interface ConditionSqlContext {
|
|
21
|
+
/** The qualified tables the condition reads. */
|
|
22
|
+
tables: {
|
|
23
|
+
event: string;
|
|
24
|
+
suppression: string;
|
|
25
|
+
device: string;
|
|
26
|
+
};
|
|
27
|
+
/** The column holding the user id. */
|
|
28
|
+
user: string;
|
|
29
|
+
/** The column holding the user's profile props, NULL when there is no profile. */
|
|
30
|
+
props: string;
|
|
31
|
+
nowMs: number;
|
|
32
|
+
/** Every deployed segment, for `segment(...)` references. */
|
|
33
|
+
segments: ReadonlyMap<string, ConditionIR>;
|
|
34
|
+
/** Registers a parameter and returns its placeholder (`$3`). */
|
|
35
|
+
bind(value: unknown): string;
|
|
36
|
+
}
|
|
37
|
+
export declare function conditionToSql(condition: ConditionIR, context: ConditionSqlContext): string;
|
|
38
|
+
//# sourceMappingURL=condition-sql.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"condition-sql.d.cts","names":[],"sources":["../../src/store/condition-sql.ts"],"mappings":";;;;;;;;;;;;;;;;;;;iBAuBiB;;EAEf;IAAU;IAAe;IAAqB;;;EAE9C;;EAEA;EACA;;EAEA,UAAU,oBAAoB;;EAE9B,KAAK;;wBAQS,eAAe,WAAW,aAAa,SAAS"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ConditionIR } from "../ir.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* A condition as one SQL boolean over a whole set of users, for the segment sweep: where
|
|
4
|
+
* `engine/condition.ts` asks about one user in several round trips, this asks about all of them
|
|
5
|
+
* in one statement.
|
|
6
|
+
*
|
|
7
|
+
* `engine/condition.ts` stays the authority, and this only narrows down who it has to look at.
|
|
8
|
+
* So the result is three-valued: TRUE and FALSE state what the evaluator would answer, and NULL
|
|
9
|
+
* means "ask the evaluator". SQL's AND, OR and NOT carry a NULL exactly as far as it can affect
|
|
10
|
+
* the outcome, so a leaf that cannot match JavaScript's semantics returns NULL and nothing above
|
|
11
|
+
* it has to know. That covers what SQL has no clean form for: a comparison across types (`'5' > 3`
|
|
12
|
+
* coerces in JavaScript), a segment that is unknown or reaches itself (the evaluator throws, and
|
|
13
|
+
* its error is the one to report), hand-built IR.
|
|
14
|
+
*
|
|
15
|
+
* Injection: nothing from the condition is ever written into the statement. Every name, path and
|
|
16
|
+
* value goes through `bind`, as does the clock; the only other text is this file's own SQL, the
|
|
17
|
+
* store's table names and a count checked to be an integer. Keep it that way: a segment id or a
|
|
18
|
+
* property path is data, however trusted the bundle that carries it.
|
|
19
|
+
*/
|
|
20
|
+
export interface ConditionSqlContext {
|
|
21
|
+
/** The qualified tables the condition reads. */
|
|
22
|
+
tables: {
|
|
23
|
+
event: string;
|
|
24
|
+
suppression: string;
|
|
25
|
+
device: string;
|
|
26
|
+
};
|
|
27
|
+
/** The column holding the user id. */
|
|
28
|
+
user: string;
|
|
29
|
+
/** The column holding the user's profile props, NULL when there is no profile. */
|
|
30
|
+
props: string;
|
|
31
|
+
nowMs: number;
|
|
32
|
+
/** Every deployed segment, for `segment(...)` references. */
|
|
33
|
+
segments: ReadonlyMap<string, ConditionIR>;
|
|
34
|
+
/** Registers a parameter and returns its placeholder (`$3`). */
|
|
35
|
+
bind(value: unknown): string;
|
|
36
|
+
}
|
|
37
|
+
export declare function conditionToSql(condition: ConditionIR, context: ConditionSqlContext): string;
|
|
38
|
+
//# sourceMappingURL=condition-sql.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"condition-sql.d.mts","names":[],"sources":["../../src/store/condition-sql.ts"],"mappings":";;;;;;;;;;;;;;;;;;;iBAuBiB;;EAEf;IAAU;IAAe;IAAqB;;;EAE9C;;EAEA;EACA;;EAEA,UAAU,oBAAoB;;EAE9B,KAAK;;wBAQS,eAAe,WAAW,aAAa,SAAS"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { RECIPIENT_PROPERTY } from "../engine/recipient.mjs";
|
|
2
|
+
import "./facts.mjs";
|
|
3
|
+
// src/store/condition-sql.ts
|
|
4
|
+
const UNKNOWN = "NULL::boolean";
|
|
5
|
+
/** `to_timestamp`, as every other read of `ts` in the store writes it. */
|
|
6
|
+
const timestamp = (param) => `to_timestamp(${param}::double precision / 1000)`;
|
|
7
|
+
function conditionToSql(condition, context) {
|
|
8
|
+
if (JSON.stringify(condition).includes("\\u0000")) return UNKNOWN;
|
|
9
|
+
return compile(condition, context, []);
|
|
10
|
+
}
|
|
11
|
+
function compile(condition, context, enclosing) {
|
|
12
|
+
switch (condition.type) {
|
|
13
|
+
case "and": return join(condition.conditions.map((child) => compile(child, context, enclosing)), "AND", "TRUE");
|
|
14
|
+
case "or": return join(condition.conditions.map((child) => compile(child, context, enclosing)), "OR", "FALSE");
|
|
15
|
+
case "not": return `(NOT ${compile(condition.condition, context, enclosing)})`;
|
|
16
|
+
case "segment": {
|
|
17
|
+
const definition = context.segments.get(condition.segment);
|
|
18
|
+
if (definition === void 0 || enclosing.includes(condition.segment)) return UNKNOWN;
|
|
19
|
+
return compile(definition, context, [...enclosing, condition.segment]);
|
|
20
|
+
}
|
|
21
|
+
case "performed": return performed(condition, context);
|
|
22
|
+
case "property": return compare(condition, once(() => profileScalar(condition.path, context)), context);
|
|
23
|
+
case "reachable": return reachable(condition.channel, context);
|
|
24
|
+
case "payload": return UNKNOWN;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function once(make) {
|
|
28
|
+
let made;
|
|
29
|
+
return () => made ??= make();
|
|
30
|
+
}
|
|
31
|
+
function join(parts, operator, empty) {
|
|
32
|
+
return parts.length === 0 ? empty : `(${parts.join(` ${operator} `)})`;
|
|
33
|
+
}
|
|
34
|
+
/** Mirrors `JourneyFactSource.countEvents`: the window's bounds are inclusive at both ends. */
|
|
35
|
+
function performed(condition, context) {
|
|
36
|
+
const atLeast = Math.ceil(condition.count ?? 1);
|
|
37
|
+
if (!Number.isSafeInteger(atLeast)) return UNKNOWN;
|
|
38
|
+
if (atLeast <= 0) return "TRUE";
|
|
39
|
+
const since = condition.within === void 0 ? void 0 : context.nowMs - condition.within.ms;
|
|
40
|
+
if (since !== void 0 && !Number.isFinite(since)) return UNKNOWN;
|
|
41
|
+
let events = `SELECT 1 FROM ${context.tables.event} ev WHERE ev.user_id = ${context.user} AND ev.name = ${context.bind(condition.event)}::text`;
|
|
42
|
+
if (since !== void 0) events += ` AND ev.ts >= ${timestamp(context.bind(since))}`;
|
|
43
|
+
if (condition.where === void 0) return reaches(events, atLeast);
|
|
44
|
+
const where = whereToSql(condition.where, context);
|
|
45
|
+
return `(CASE WHEN ${reaches(`${events} AND (${where}) IS TRUE`, atLeast)} THEN TRUE WHEN ${reaches(`${events} AND (${where}) IS NOT FALSE`, atLeast)} THEN ${UNKNOWN} ELSE FALSE END)`;
|
|
46
|
+
}
|
|
47
|
+
/** Whether a query yields `atLeast` rows, stopping there: the (user_id, name, ts) index serves it. */
|
|
48
|
+
function reaches(query, atLeast) {
|
|
49
|
+
return atLeast === 1 ? `EXISTS (${query})` : `((SELECT count(*) FROM (${query} LIMIT ${String(atLeast)}) capped) >= ${String(atLeast)})`;
|
|
50
|
+
}
|
|
51
|
+
/** Mirrors `matchesWhere`, over the event row `ev`. */
|
|
52
|
+
function whereToSql(where, context) {
|
|
53
|
+
switch (where.type) {
|
|
54
|
+
case "and": return join(where.conditions.map((child) => whereToSql(child, context)), "AND", "TRUE");
|
|
55
|
+
case "or": return join(where.conditions.map((child) => whereToSql(child, context)), "OR", "FALSE");
|
|
56
|
+
case "not": return `(NOT ${whereToSql(where.condition, context)})`;
|
|
57
|
+
case "payload": {
|
|
58
|
+
const keys = where.path.split(".");
|
|
59
|
+
if (!keys.every(isPlainKey)) return UNKNOWN;
|
|
60
|
+
const raw = once(() => keys.reduce((json, key) => `${json} -> ${context.bind(key)}::text`, "ev.payload"));
|
|
61
|
+
const present = () => `(jsonb_typeof(${raw()}) IS DISTINCT FROM 'null' AND ${raw()} IS NOT NULL)`;
|
|
62
|
+
if (where.op === "exists") return present();
|
|
63
|
+
if (where.op === "not_exists") return `(NOT ${present()})`;
|
|
64
|
+
return compare(where, once(() => scalarOf(raw())), context);
|
|
65
|
+
}
|
|
66
|
+
default: return UNKNOWN;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A key `valueAtPath` finds in plain JSON objects only. It indexes with `current[key]`, so a
|
|
71
|
+
* number reaches into an array, `length` reads an array's size and `toString` finds the prototype,
|
|
72
|
+
* none of which `->` does.
|
|
73
|
+
*/
|
|
74
|
+
function isPlainKey(key) {
|
|
75
|
+
return !/^\d+$/.test(key) && key !== "length" && !(key in Object.prototype);
|
|
76
|
+
}
|
|
77
|
+
/** The value if it is a JSON scalar, SQL NULL otherwise: a non-scalar reads as absent. */
|
|
78
|
+
function scalarOf(json) {
|
|
79
|
+
return `(CASE WHEN jsonb_typeof(${json}) IN ('string', 'number', 'boolean') THEN ${json} END)`;
|
|
80
|
+
}
|
|
81
|
+
/** Mirrors `JourneyFactSource.getProperty`. */
|
|
82
|
+
function profileScalar(path, context) {
|
|
83
|
+
if (path === "userId") return `to_jsonb(${context.user})`;
|
|
84
|
+
return scalarOf(`${context.props} -> ${context.bind(path)}::text`);
|
|
85
|
+
}
|
|
86
|
+
function reachable(channel, context) {
|
|
87
|
+
const suppressed = `EXISTS (SELECT 1 FROM ${context.tables.suppression} s WHERE s.user_id = ${context.user} AND s.channel = ${context.bind(channel)}::text AND s.topic IS NULL)`;
|
|
88
|
+
let address;
|
|
89
|
+
if (channel === "push") address = `EXISTS (SELECT 1 FROM ${context.tables.device} d WHERE d.user_id = ${context.user})`;
|
|
90
|
+
else {
|
|
91
|
+
const path = RECIPIENT_PROPERTY[channel];
|
|
92
|
+
address = path === void 0 ? "TRUE" : `${profileScalar(path, context)} IS NOT NULL`;
|
|
93
|
+
}
|
|
94
|
+
return `(NOT ${suppressed} AND ${address})`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Mirrors `compareProperty`. `actual` yields a jsonb scalar, or SQL NULL for "undefined". It is
|
|
98
|
+
* asked for only by a branch that uses it: Postgres cannot type a parameter no statement mentions,
|
|
99
|
+
* so an operator that needs no value (`between` without a range) must not bind one.
|
|
100
|
+
*/
|
|
101
|
+
function compare(condition, actual, context) {
|
|
102
|
+
const { value, values } = condition;
|
|
103
|
+
const json = (scalar) => `${context.bind(JSON.stringify(scalar))}::text::jsonb`;
|
|
104
|
+
const isIn = () => (values ?? []).length === 0 ? "FALSE" : `COALESCE(${actual()} IN (${(values ?? []).map(json).join(", ")}), FALSE)`;
|
|
105
|
+
const isBetween = () => {
|
|
106
|
+
if (values === void 0 || values.length < 2) return "FALSE";
|
|
107
|
+
const [min, max] = values;
|
|
108
|
+
return `(${order(actual, ">=", min, context)} AND ${order(actual, "<=", max, context)})`;
|
|
109
|
+
};
|
|
110
|
+
const contains = () => typeof value === "string" ? `COALESCE(jsonb_typeof(${actual()}) = 'string' AND strpos(${actual()} #>> '{}', ${context.bind(value)}::text) > 0, FALSE)` : "FALSE";
|
|
111
|
+
switch (condition.op) {
|
|
112
|
+
case "exists": return `(${actual()} IS NOT NULL)`;
|
|
113
|
+
case "not_exists": return `(${actual()} IS NULL)`;
|
|
114
|
+
case "eq": return value === void 0 ? `(${actual()} IS NULL)` : `COALESCE(${actual()} = ${json(value)}, FALSE)`;
|
|
115
|
+
case "ne": return value === void 0 ? `(${actual()} IS NOT NULL)` : `(${actual()} IS DISTINCT FROM ${json(value)})`;
|
|
116
|
+
case "gt": return order(actual, ">", value, context);
|
|
117
|
+
case "gte": return order(actual, ">=", value, context);
|
|
118
|
+
case "lt": return order(actual, "<", value, context);
|
|
119
|
+
case "lte": return order(actual, "<=", value, context);
|
|
120
|
+
case "between": return isBetween();
|
|
121
|
+
case "not_between": return `(NOT ${isBetween()})`;
|
|
122
|
+
case "in_array": return isIn();
|
|
123
|
+
case "not_in_array": return `(NOT ${isIn()})`;
|
|
124
|
+
case "contains": return contains();
|
|
125
|
+
case "not_contains": return `(NOT ${contains()})`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* An ordering of two values of one type; across types JavaScript coerces, so that is the
|
|
130
|
+
* evaluator's to answer. Strings compare by code point, what `COLLATE "C"` does on UTF-8: JavaScript
|
|
131
|
+
* compares UTF-16 units, which differs only between an astral character and U+E000 to U+FFFF.
|
|
132
|
+
*/
|
|
133
|
+
function order(scalar, operator, value, context) {
|
|
134
|
+
if (value === void 0) return "FALSE";
|
|
135
|
+
const actual = scalar();
|
|
136
|
+
let same = UNKNOWN;
|
|
137
|
+
if (typeof value === "number" && Number.isFinite(value)) same = `(CASE WHEN jsonb_typeof(${actual}) = 'number' THEN (${actual})::numeric ${operator} ${`${context.bind(String(value))}::text::numeric`} END)`;
|
|
138
|
+
else if (typeof value === "string") same = `(CASE WHEN jsonb_typeof(${actual}) = 'string' THEN (${actual} #>> '{}') COLLATE "C" ${operator} ${`${context.bind(value)}::text`} END)`;
|
|
139
|
+
return `(CASE WHEN ${actual} IS NULL THEN FALSE ELSE ${same} END)`;
|
|
140
|
+
}
|
|
141
|
+
export { conditionToSql };
|
|
142
|
+
|
|
143
|
+
//# sourceMappingURL=condition-sql.mjs.map
|