@drip-apex/sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19283 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19267 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16636 -13533
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +28 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2071 -0
  128. package/src/consent-evaluation-borlabs.test.ts +41 -0
  129. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  130. package/src/consent-evaluation.ts +151 -0
  131. package/src/css.test.ts +30 -0
  132. package/src/css.ts +120 -6
  133. package/src/cwv.test.ts +60 -77
  134. package/src/debug-loader.test.ts +214 -0
  135. package/src/debug-loader.ts +41 -0
  136. package/src/decide-assignments.test.ts +196 -0
  137. package/src/decide-assignments.ts +204 -0
  138. package/src/delivery-timing.test.ts +104 -0
  139. package/src/delivery-timing.ts +89 -0
  140. package/src/early-interaction-capture.test.ts +451 -0
  141. package/src/early-interaction-capture.ts +230 -0
  142. package/src/early-reassert.test.ts +207 -0
  143. package/src/early-reassert.ts +152 -0
  144. package/src/edge-adoption.test.ts +435 -0
  145. package/src/edge-runtime.ts +30 -0
  146. package/src/editor/api.test.ts +109 -0
  147. package/src/editor/api.ts +34 -3
  148. package/src/editor/controller.test.ts +297 -14
  149. package/src/editor/controller.ts +69 -5
  150. package/src/editor/index.test.ts +2 -0
  151. package/src/editor/index.ts +1 -0
  152. package/src/editor/publication-result.ts +58 -0
  153. package/src/editor/types.ts +10 -1
  154. package/src/editor/view-kit.ts +2 -1
  155. package/src/editor/view.test.ts +108 -0
  156. package/src/editor/view.ts +49 -7
  157. package/src/endpoint.test.ts +15 -0
  158. package/src/endpoint.ts +10 -0
  159. package/src/exclusion-groups.ts +8 -18
  160. package/src/flags.test.ts +88 -1
  161. package/src/flags.ts +29 -29
  162. package/src/global-runtime-reconciler.test.ts +271 -0
  163. package/src/goal-eligibility.test.ts +29 -0
  164. package/src/goal-eligibility.ts +17 -0
  165. package/src/goals.test.ts +144 -1
  166. package/src/goals.ts +97 -18
  167. package/src/guards.test.ts +99 -1
  168. package/src/guards.ts +34 -15
  169. package/src/handover-cache-order.test.ts +6 -3
  170. package/src/handover.test.ts +154 -8
  171. package/src/handover.ts +122 -12
  172. package/src/hash.ts +29 -54
  173. package/src/holdout.test.ts +26 -0
  174. package/src/hydration.test.ts +73 -2
  175. package/src/hydration.ts +18 -9
  176. package/src/index.ts +130 -7287
  177. package/src/init-analytics.test.ts +1694 -0
  178. package/src/init-anti-flicker.test.ts +1211 -0
  179. package/src/init-cart-attributes.test.ts +2003 -0
  180. package/src/init-config-resolution.test.ts +999 -0
  181. package/src/init-consent.test.ts +971 -0
  182. package/src/init-empty.test.ts +11 -5406
  183. package/src/init-integrations.test.ts +846 -0
  184. package/src/init-page-activation.test.ts +5064 -0
  185. package/src/init-qa-loading.test.ts +740 -0
  186. package/src/init-redirects.test.ts +2156 -0
  187. package/src/init-test-helpers.ts +287 -0
  188. package/src/inline-bootstrap.test.ts +184 -0
  189. package/src/inline-bootstrap.ts +73 -0
  190. package/src/js.test.ts +51 -1
  191. package/src/js.ts +86 -2
  192. package/src/money.test.ts +55 -1
  193. package/src/money.ts +104 -2
  194. package/src/money.web-parity.contract.test.ts +85 -0
  195. package/src/mutate.test.ts +474 -10
  196. package/src/mutate.ts +739 -230
  197. package/src/page-trigger.test.ts +218 -8
  198. package/src/performance.ts +10 -0
  199. package/src/persistence-decoders.ts +18 -0
  200. package/src/public-api.ts +1828 -0
  201. package/src/qa-assistant-state.test.ts +249 -3
  202. package/src/qa-assistant-state.ts +146 -6
  203. package/src/qa-assistant-view.test.ts +220 -2
  204. package/src/qa-assistant-view.ts +102 -5
  205. package/src/react/index.test.tsx +37 -0
  206. package/src/react/index.tsx +19 -3
  207. package/src/reassert-sentinel.test.ts +155 -1
  208. package/src/reassert-sentinel.ts +168 -8
  209. package/src/redirect.test.ts +541 -0
  210. package/src/redirect.ts +572 -0
  211. package/src/replay-recorder-entry.ts +115 -0
  212. package/src/replay-signal-state.ts +100 -0
  213. package/src/replay-signals.test.ts +249 -0
  214. package/src/replay-signals.ts +151 -0
  215. package/src/replay.test.ts +764 -0
  216. package/src/replay.ts +514 -0
  217. package/src/router.test.ts +61 -2
  218. package/src/router.ts +50 -12
  219. package/src/runtime-state.ts +507 -0
  220. package/src/runtime.test.ts +174 -7
  221. package/src/runtime.ts +88 -14
  222. package/src/sanitize.test.ts +34 -0
  223. package/src/sanitize.ts +23 -12
  224. package/src/shadow.test.ts +187 -0
  225. package/src/shadow.ts +99 -0
  226. package/src/shopify-cart.test.ts +733 -23
  227. package/src/shopify-cart.ts +402 -49
  228. package/src/storefront-vitals.test.ts +217 -0
  229. package/src/storefront-vitals.ts +206 -0
  230. package/src/surface-signals.ts +6 -0
  231. package/src/targeting-conformance.test.ts +9 -0
  232. package/src/targeting-diagnostics.ts +38 -8
  233. package/src/targeting-parity-corpus.json +559 -0
  234. package/src/targeting-parity-corpus.test.ts +18 -0
  235. package/src/targeting.detailed.test.ts +105 -0
  236. package/src/targeting.test.ts +211 -0
  237. package/src/targeting.ts +88 -207
  238. package/src/track.test.ts +147 -0
  239. package/src/track.ts +125 -26
  240. package/src/types.ts +120 -13
  241. package/src/wait.test.ts +23 -0
  242. package/src/wait.ts +129 -13
  243. package/src/weekday-delivery.test.ts +56 -0
  244. package/src/weekday-schedule-parity-corpus.json +92 -0
  245. package/src/weekday-schedule.test.ts +69 -0
  246. package/src/weekday-schedule.ts +20 -0
@@ -160,6 +160,56 @@ describe("editor view v2", () => {
160
160
  cleanup();
161
161
  });
162
162
 
163
+ it("renders saved-but-pending as a warning instead of saved or failed", () => {
164
+ const controller = makeController(
165
+ state({
166
+ saveState: "pending",
167
+ saveMessage: "Saved, but publication is pending.",
168
+ }),
169
+ );
170
+ const { shadow, cleanup } = mount(controller);
171
+
172
+ const chip = shadow.querySelector(".save-chip");
173
+ expect(chip?.classList.contains("pending")).toBe(true);
174
+ expect(chip?.textContent).toContain("Saved — publication pending");
175
+ expect(chip?.classList.contains("saved")).toBe(false);
176
+ expect(chip?.classList.contains("failed")).toBe(false);
177
+ cleanup();
178
+ });
179
+
180
+ it("renders a soft publication warning separately from full success", () => {
181
+ const controller = makeController(
182
+ state({
183
+ saveState: "warning",
184
+ saveMessage: "Saved and published, but one publication step is incomplete.",
185
+ }),
186
+ );
187
+ const { shadow, cleanup } = mount(controller);
188
+
189
+ const chip = shadow.querySelector(".save-chip");
190
+ expect(chip?.classList.contains("warning")).toBe(true);
191
+ expect(chip?.textContent).toContain("Saved — publication warning");
192
+ expect(chip?.classList.contains("saved")).toBe(false);
193
+ cleanup();
194
+ });
195
+
196
+ it("renders committed publication failure separately from a save failure", () => {
197
+ const controller = makeController(
198
+ state({
199
+ saveState: "publication_failed",
200
+ saveMessage: "Saved, but storefront publication failed.",
201
+ }),
202
+ );
203
+ const { shadow, cleanup } = mount(controller);
204
+
205
+ const chip = shadow.querySelector(".save-chip");
206
+ expect(chip?.classList.contains("publication-failed")).toBe(true);
207
+ expect(chip?.textContent).toContain("Saved — publication failed");
208
+ expect(chip?.textContent).not.toContain("Save failed");
209
+ expect(chip?.querySelector('[data-action="retry-save"]')).not.toBeNull();
210
+ cleanup();
211
+ });
212
+
163
213
  it("runs an Operator turn from the conversation and reports the result card", async () => {
164
214
  const generated = {
165
215
  mutations: [{ selector: "h1", action: "setText" as const, text: "Generated" }],
@@ -326,6 +376,45 @@ describe("editor view v2", () => {
326
376
  cleanup();
327
377
  });
328
378
 
379
+ it("reports an Operator result as saved with failed publication", async () => {
380
+ const generated = {
381
+ mutations: [{ selector: "h1", action: "setText" as const, text: "Committed result" }],
382
+ customCss: "",
383
+ customJs: "",
384
+ };
385
+ vi.spyOn(operator, "runEditorOperatorTurn").mockResolvedValueOnce({
386
+ artifacts: generated,
387
+ explanation: "Updated the heading.",
388
+ artifactCount: 1,
389
+ mergeStrategy: "append",
390
+ });
391
+ const controller = makeController();
392
+ controller.applyCommandArtifacts.mockImplementationOnce((artifacts) => {
393
+ controller.emit(state({ artifacts, saveState: "idle", saveMessage: "Unsaved changes" }));
394
+ return { ok: true };
395
+ });
396
+ controller.saveNow.mockImplementationOnce(async () => {
397
+ controller.emit(state({
398
+ artifacts: generated,
399
+ saveState: "publication_failed",
400
+ saveMessage: "Saved, but storefront publication failed.",
401
+ }));
402
+ });
403
+ const { shadow, cleanup } = mount(controller);
404
+
405
+ const prompt = shadow.querySelector<HTMLTextAreaElement>('[data-field="operator-prompt"]')!;
406
+ prompt.value = "Update the heading";
407
+ prompt.dispatchEvent(new Event("input", { bubbles: true }));
408
+ prompt.closest("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
409
+
410
+ await vi.waitFor(() =>
411
+ expect(shadow.textContent).toContain("Change saved, but publication failed"),
412
+ );
413
+ expect(shadow.textContent).toContain("The change is saved in Apex");
414
+ expect(shadow.textContent).not.toContain("Change applied, but not saved");
415
+ cleanup();
416
+ });
417
+
329
418
  it("disables run-level undo after a later manual edit changes the artifact", async () => {
330
419
  const generated = {
331
420
  mutations: [{ selector: "h1", action: "setText" as const, text: "Generated" }],
@@ -505,6 +594,25 @@ describe("editor view v2", () => {
505
594
  cleanup();
506
595
  });
507
596
 
597
+ it("keeps the editor open when Save and close cannot publish", async () => {
598
+ const controller = makeController();
599
+ controller.saveNow.mockImplementationOnce(async () => {
600
+ controller.emit(state({
601
+ saveState: "publication_failed",
602
+ saveMessage: "Saved, but storefront publication failed.",
603
+ }));
604
+ });
605
+ const { shadow, cleanup } = mount(controller);
606
+
607
+ shadow.querySelector<HTMLButtonElement>('[data-action="done"]')?.click();
608
+ shadow.querySelector<HTMLButtonElement>('[data-action="modal-save-close"]')?.click();
609
+
610
+ await vi.waitFor(() => expect(controller.saveNow).toHaveBeenCalled());
611
+ expect(controller.exit).not.toHaveBeenCalled();
612
+ expect(shadow.textContent).toContain("Done editing?");
613
+ cleanup();
614
+ });
615
+
508
616
  it("extends the session and refreshes the displayed time", async () => {
509
617
  const controller = makeController();
510
618
  const extended = new Date(Date.now() + 4 * 60 * 1000).toISOString();
@@ -198,7 +198,7 @@ type ThreadUserMessage = {
198
198
  type ThreadOperatorMessage = {
199
199
  id: number;
200
200
  role: "op";
201
- phase: "working" | "done" | "failed" | "save-failed";
201
+ phase: "working" | "done" | "failed" | "save-failed" | "publication-failed";
202
202
  step: number;
203
203
  steps: string[];
204
204
  prompt: string;
@@ -224,7 +224,16 @@ type ChangeEntry = {
224
224
  risky: boolean;
225
225
  };
226
226
 
227
- type SaveChipStatus = "unsaved" | "saving" | "saved" | "failed" | "conflict" | "loading";
227
+ type SaveChipStatus =
228
+ | "unsaved"
229
+ | "saving"
230
+ | "saved"
231
+ | "pending"
232
+ | "warning"
233
+ | "publication-failed"
234
+ | "failed"
235
+ | "conflict"
236
+ | "loading";
228
237
 
229
238
  const OPERATOR_SUGGESTIONS = [
230
239
  "Make the announcement bar mention free returns",
@@ -269,6 +278,9 @@ function elementNameFromSelector(selector: string): string {
269
278
  function saveChipStatus(state: EditorState): SaveChipStatus {
270
279
  if (state.saveState === "saving") return "saving";
271
280
  if (state.saveState === "saved") return "saved";
281
+ if (state.saveState === "pending") return "pending";
282
+ if (state.saveState === "warning") return "warning";
283
+ if (state.saveState === "publication_failed") return "publication-failed";
272
284
  if (state.saveState === "conflict") return "conflict";
273
285
  if (state.saveState === "error") return "failed";
274
286
  if (!state.variation) return "loading";
@@ -281,15 +293,19 @@ function saveChipHtml(state: EditorState): string {
281
293
  status === "unsaved" ? "Unsaved changes"
282
294
  : status === "saving" ? "Saving…"
283
295
  : status === "saved" ? "Saved"
296
+ : status === "pending" ? "Saved — publication pending"
297
+ : status === "warning" ? "Saved — publication warning"
298
+ : status === "publication-failed" ? "Saved — publication failed"
284
299
  : status === "failed" ? "Save failed"
285
300
  : status === "conflict" ? "Conflict"
286
301
  : "Loading draft…";
287
302
  const iconHtml =
288
303
  status === "saving" || status === "loading" ? icon("refresh", 12, 'class="spin"')
289
304
  : status === "saved" ? icon("check", 12)
305
+ : status === "pending" || status === "warning" ? icon("alert", 12)
290
306
  : status === "unsaved" ? icon("dot", 12)
291
307
  : icon("alert", 12);
292
- const retry = status === "failed"
308
+ const retry = status === "failed" || status === "publication-failed"
293
309
  ? `<button type="button" class="retry" data-action="retry-save">Retry</button>`
294
310
  : "";
295
311
  const chipClass = status === "loading" ? "saving" : status;
@@ -602,6 +618,13 @@ export function mountEditorView(
602
618
  <div class="row"><button type="button" class="btn sm soft" data-action="retry-save" data-msg="${message.id}">${icon("refresh", 12)}Retry save</button><button type="button" class="btn sm bare" data-action="op-all-changes">${icon("history", 12)}Review changes</button></div>
603
619
  </div>`;
604
620
  }
621
+ if (message.phase === "publication-failed") {
622
+ return `<div class="op-card failed ee-in" role="alert">
623
+ <div class="head">${icon("alert", 13)} Change saved, but publication failed</div>
624
+ <p>${esc(message.error ?? "Storefront publication failed.")} The change is saved in Apex. Keep the editor open and retry publication.</p>
625
+ <div class="row"><button type="button" class="btn sm soft" data-action="retry-save" data-msg="${message.id}">${icon("refresh", 12)}Retry publication</button><button type="button" class="btn sm bare" data-action="op-all-changes">${icon("history", 12)}Review changes</button></div>
626
+ </div>`;
627
+ }
605
628
  const count = message.results.length;
606
629
  const canUndo = canUndoRun(message);
607
630
  return `<div class="op-card done ee-in">
@@ -1017,8 +1040,15 @@ export function mountEditorView(
1017
1040
  opMessage.abort = null;
1018
1041
  await controller.saveNow();
1019
1042
  const saveState = controller.getState().saveState;
1020
- if (saveState === "error" || saveState === "conflict") {
1021
- opMessage.phase = "save-failed";
1043
+ if (
1044
+ saveState === "error" ||
1045
+ saveState === "conflict" ||
1046
+ saveState === "publication_failed"
1047
+ ) {
1048
+ opMessage.phase =
1049
+ saveState === "publication_failed"
1050
+ ? "publication-failed"
1051
+ : "save-failed";
1022
1052
  opMessage.abort = null;
1023
1053
  opMessage.results = describeRunChanges(preArtifacts, controller.getState().artifacts);
1024
1054
  opMessage.error = controller.getState().saveMessage;
@@ -1091,7 +1121,13 @@ export function mountEditorView(
1091
1121
  );
1092
1122
  await controller.saveNow();
1093
1123
  const saveState = controller.getState().saveState;
1094
- if (message?.phase === "save-failed" && saveState !== "error" && saveState !== "conflict") {
1124
+ if (
1125
+ (message?.phase === "save-failed" ||
1126
+ message?.phase === "publication-failed") &&
1127
+ saveState !== "error" &&
1128
+ saveState !== "conflict" &&
1129
+ saveState !== "publication_failed"
1130
+ ) {
1095
1131
  message.phase = "done";
1096
1132
  message.error = null;
1097
1133
  message.postArtifacts = stillMatchesRun
@@ -1218,7 +1254,13 @@ export function mountEditorView(
1218
1254
  case "modal-save-close":
1219
1255
  void controller.saveNow().then(() => {
1220
1256
  const saveState = controller.getState().saveState;
1221
- if (saveState !== "error" && saveState !== "conflict") return controller.exit();
1257
+ if (
1258
+ saveState !== "error" &&
1259
+ saveState !== "conflict" &&
1260
+ saveState !== "publication_failed"
1261
+ ) {
1262
+ return controller.exit();
1263
+ }
1222
1264
  render();
1223
1265
  }).catch(() => render());
1224
1266
  break;
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeEndpoint } from "./endpoint";
3
+
4
+ describe("normalizeEndpoint", () => {
5
+ it("resolves every configured endpoint form to the same base", () => {
6
+ expect(normalizeEndpoint("https://events.example.com")).toBe("https://events.example.com");
7
+ expect(normalizeEndpoint("https://events.example.com/")).toBe("https://events.example.com");
8
+ expect(normalizeEndpoint("https://events.example.com/events")).toBe("https://events.example.com");
9
+ expect(normalizeEndpoint("https://events.example.com/v1/events")).toBe("https://events.example.com");
10
+ });
11
+
12
+ it("keeps the edge-routed prefix so /_drip siblings still resolve", () => {
13
+ expect(normalizeEndpoint("https://shop.example.com/_drip/events")).toBe("https://shop.example.com/_drip");
14
+ });
15
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Normalize an SDK endpoint: strip trailing slashes and a trailing events path
3
+ * so configured bare, /events, and /v1/events endpoints resolve to the base.
4
+ * Single chokepoint — every SDK helper that derives a sibling route from
5
+ * config.endpoint must go through this (events, handover, order-identity,
6
+ * replay, shopify cart authorization).
7
+ */
8
+ export function normalizeEndpoint(endpoint: string): string {
9
+ return endpoint.replace(/\/+$/, "").replace(/\/(?:v1\/)?events$/, "");
10
+ }
@@ -2,17 +2,15 @@ import type { Assignment, ExclusionGroupConfig } from "./types";
2
2
  import { hashToFloat } from "./hash";
3
3
  import { chooseVariation } from "./bucket";
4
4
  import { deleteCookie, readCookie, writeCookie } from "./guards";
5
+ import { decodeCompactStore } from "./compact-store";
6
+ import { decodePersistedGroupSelections, type PersistedGroupSelection } from "./persistence-decoders";
7
+ export { decodePersistedGroupSelections, type PersistedGroupSelection } from "./persistence-decoders";
5
8
 
6
9
  const STORAGE_KEY = "drip_xgroups_v1"; // gitleaks:allow — public storage key name, not a credential
7
10
  const COOKIE_NAME = "drip_xgroups_v1";
8
11
  const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
9
12
 
10
13
  /** A remembered group decision: which member won and at what group epoch. */
11
- export type PersistedGroupSelection = {
12
- experimentId: string;
13
- epoch: number;
14
- };
15
-
16
14
  /** Pluggable persistence so the runtime uses browser storage and tests use memory. */
17
15
  export interface GroupStickyStore {
18
16
  read(groupId: string): PersistedGroupSelection | null;
@@ -26,16 +24,8 @@ function normalizeEpoch(epoch: number | null | undefined): number {
26
24
  return Number.isFinite(epoch) ? Math.max(1, Math.floor(epoch as number)) : 1;
27
25
  }
28
26
 
29
- function parseStore(raw: string | null): CompactStore {
30
- if (!raw) return {};
31
- try {
32
- const parsed = JSON.parse(raw) as unknown;
33
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
34
- ? (parsed as CompactStore)
35
- : {};
36
- } catch {
37
- return {};
38
- }
27
+ export function decodeCompactGroupStore(raw: string | null): CompactStore {
28
+ return decodeCompactStore(raw, 2) as CompactStore;
39
29
  }
40
30
 
41
31
  function readLocalStorage(): string | null {
@@ -48,8 +38,8 @@ function readLocalStorage(): string | null {
48
38
 
49
39
  function readCompactStore(): CompactStore {
50
40
  return {
51
- ...parseStore(readLocalStorage()),
52
- ...parseStore(readCookie(COOKIE_NAME)),
41
+ ...decodeCompactGroupStore(readLocalStorage()),
42
+ ...decodeCompactGroupStore(readCookie(COOKIE_NAME)),
53
43
  };
54
44
  }
55
45
 
@@ -265,6 +255,6 @@ export function resolveExclusionGroups(
265
255
  export const __exclusionGroupInternals = {
266
256
  STORAGE_KEY,
267
257
  COOKIE_NAME,
268
- parseStore,
258
+ parseStore: decodeCompactGroupStore,
269
259
  normalizeEpoch,
270
260
  };
package/src/flags.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  evaluateFlag,
4
4
  evaluateFlagAssignment,
@@ -7,6 +7,15 @@ import {
7
7
  } from "./flags";
8
8
  import type { Experiment } from "./types";
9
9
 
10
+ // Hermeticity: weekday tests pin the most recent UTC Monday at 12:00 instead
11
+ // of a literal calendar date.
12
+ const RECENT_MONDAY_NOON = (() => {
13
+ const d = new Date();
14
+ d.setUTCDate(d.getUTCDate() - ((d.getUTCDay() + 6) % 7));
15
+ d.setUTCHours(12, 0, 0, 0);
16
+ return d;
17
+ })();
18
+
10
19
  describe("defineFlagExperiment", () => {
11
20
  it("creates exactly 2 variations", () => {
12
21
  const flag = defineFlagExperiment({
@@ -320,3 +329,81 @@ describe("evaluateFlagValue", () => {
320
329
  ).toBe("fallback");
321
330
  });
322
331
  });
332
+
333
+ describe("weekday flag delivery", () => {
334
+ afterEach(() => {
335
+ vi.useRealTimers();
336
+ });
337
+
338
+ function scheduledFlag() {
339
+ return {
340
+ ...defineFlagExperiment({
341
+ slug: "weekday-flag",
342
+ name: "Weekday Flag",
343
+ rolloutPercent: 0,
344
+ onValue: "enabled-value",
345
+ }),
346
+ weekdaySchedule: {
347
+ excludedDays: ["monday"],
348
+ timezone: "UTC",
349
+ },
350
+ } as Experiment;
351
+ }
352
+
353
+ it("disables all public flag evaluators on an excluded weekday", () => {
354
+ vi.useFakeTimers();
355
+ vi.setSystemTime(new Date(RECENT_MONDAY_NOON));
356
+ const flag = {
357
+ ...scheduledFlag(),
358
+ variations: scheduledFlag().variations.map((variation) => ({
359
+ ...variation,
360
+ weight: variation.id === "enabled" ? 1 : 0,
361
+ })),
362
+ };
363
+ const context = { userId: "user-abc" };
364
+
365
+ expect(evaluateFlag("weekday-flag", [flag], context)).toBe(false);
366
+ expect(evaluateFlagAssignment("weekday-flag", [flag], context)).toBeNull();
367
+ expect(
368
+ evaluateFlagValue("weekday-flag", [flag], context, "fallback"),
369
+ ).toBe("fallback");
370
+ });
371
+
372
+ it("keeps a forced variation enabled on an excluded weekday", () => {
373
+ vi.useFakeTimers();
374
+ vi.setSystemTime(new Date(RECENT_MONDAY_NOON));
375
+ const flag = scheduledFlag();
376
+ const context = {
377
+ userId: "user-abc",
378
+ forceMap: { "weekday-flag": "enabled" },
379
+ };
380
+
381
+ expect(evaluateFlag("weekday-flag", [flag], context)).toBe(true);
382
+ expect(
383
+ evaluateFlagAssignment("weekday-flag", [flag], context)?.variationId,
384
+ ).toBe("enabled");
385
+ expect(
386
+ evaluateFlagValue("weekday-flag", [flag], context, "fallback"),
387
+ ).toBe("enabled-value");
388
+ });
389
+
390
+ it("keeps flag evaluation unchanged when no weekday schedule exists", () => {
391
+ vi.useFakeTimers();
392
+ vi.setSystemTime(new Date(RECENT_MONDAY_NOON));
393
+ const flag = defineFlagExperiment({
394
+ slug: "weekday-flag",
395
+ name: "Weekday Flag",
396
+ rolloutPercent: 100,
397
+ onValue: "enabled-value",
398
+ });
399
+ const context = { userId: "user-abc" };
400
+
401
+ expect(evaluateFlag("weekday-flag", [flag], context)).toBe(true);
402
+ expect(
403
+ evaluateFlagAssignment("weekday-flag", [flag], context)?.variationId,
404
+ ).toBe("enabled");
405
+ expect(
406
+ evaluateFlagValue("weekday-flag", [flag], context, "fallback"),
407
+ ).toBe("enabled-value");
408
+ });
409
+ });
package/src/flags.ts CHANGED
@@ -22,6 +22,7 @@ import { defineExperiment } from "./define";
22
22
  import type { Experiment, Assignment } from "./types";
23
23
  import { getUserId, hashToFloat, variationHashToFloat } from "./hash";
24
24
  import { buildTargetingAttributes, matchesTargeting } from "./targeting";
25
+ import { weekdayScheduleBlocksDelivery } from "./weekday-schedule";
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // evaluateFlag — check if a flag is ON for a given user
@@ -31,6 +32,8 @@ export interface FlagContext {
31
32
  url?: string;
32
33
  attributes?: Record<string, unknown>;
33
34
  userId?: string;
35
+ /** QA variation overrides use the same weekday bypass as assignment delivery. */
36
+ forceMap?: Record<string, string>;
34
37
  }
35
38
 
36
39
  /**
@@ -46,32 +49,8 @@ export function evaluateFlag(
46
49
  experiments: Experiment[],
47
50
  context?: FlagContext,
48
51
  ): boolean {
49
- const experiment = experiments.find((e) => e.id === flagId);
50
- if (!experiment) return false;
51
-
52
- const url = context?.url ?? (typeof location !== "undefined" ? location.href : "");
53
- const attributes = buildTargetingAttributes(context?.attributes ?? {}, url);
54
-
55
- if (!matchesTargeting(experiment.targeting, { url, attributes })) {
56
- return false;
57
- }
58
-
59
- const userId = getUserId(context?.userId);
60
- const hashValue = hashToFloat(experiment.id, userId);
61
-
62
- if (!inTrafficAllocation(hashValue, experiment.trafficAllocation ?? 1)) {
63
- return false;
64
- }
65
-
66
- const allocation = experiment.trafficAllocation ?? 1;
67
- const variationHashValue =
68
- allocation >= 1 ? hashValue : variationHashToFloat(experiment.id, userId);
69
- const weights = experiment.variations.map((v) => v.weight);
70
- const index = chooseVariation(variationHashValue, weights);
71
- if (index < 0 || index >= experiment.variations.length) return false;
72
-
73
- const variation = experiment.variations[index];
74
- return !variation.isControl;
52
+ const assignment = evaluateFlagAssignment(flagId, experiments, context);
53
+ return assignment !== null && !assignment.isControl;
75
54
  }
76
55
 
77
56
  /**
@@ -85,6 +64,21 @@ export function evaluateFlagAssignment(
85
64
  const experiment = experiments.find((e) => e.id === flagId);
86
65
  if (!experiment) return null;
87
66
 
67
+ const forcedVariationId = context?.forceMap?.[experiment.id];
68
+ const forcedIndex = forcedVariationId
69
+ ? experiment.variations.findIndex(
70
+ (variation) => variation.id === forcedVariationId,
71
+ )
72
+ : -1;
73
+ const forcedVariation =
74
+ forcedIndex >= 0 ? experiment.variations[forcedIndex] : null;
75
+ if (
76
+ !forcedVariation &&
77
+ weekdayScheduleBlocksDelivery(experiment.weekdaySchedule, new Date())
78
+ ) {
79
+ return null;
80
+ }
81
+
88
82
  const url = context?.url ?? (typeof location !== "undefined" ? location.href : "");
89
83
  const attributes = buildTargetingAttributes(context?.attributes ?? {}, url);
90
84
 
@@ -95,7 +89,10 @@ export function evaluateFlagAssignment(
95
89
  const userId = getUserId(context?.userId);
96
90
  const hashValue = hashToFloat(experiment.id, userId);
97
91
 
98
- if (!inTrafficAllocation(hashValue, experiment.trafficAllocation ?? 1)) {
92
+ if (
93
+ !forcedVariation &&
94
+ !inTrafficAllocation(hashValue, experiment.trafficAllocation ?? 1)
95
+ ) {
99
96
  return null;
100
97
  }
101
98
 
@@ -103,15 +100,18 @@ export function evaluateFlagAssignment(
103
100
  const variationHashValue =
104
101
  allocation >= 1 ? hashValue : variationHashToFloat(experiment.id, userId);
105
102
  const weights = experiment.variations.map((v) => v.weight);
106
- const index = chooseVariation(variationHashValue, weights);
103
+ const index = forcedVariation
104
+ ? forcedIndex
105
+ : chooseVariation(variationHashValue, weights);
107
106
  if (index < 0 || index >= experiment.variations.length) return null;
108
107
 
109
- const variation = experiment.variations[index];
108
+ const variation = forcedVariation ?? experiment.variations[index];
110
109
 
111
110
  return {
112
111
  experimentId: experiment.id,
113
112
  experimentName: undefined,
114
113
  variationId: variation.id,
114
+ pageActive: true,
115
115
  index,
116
116
  mutations: variation.mutations ?? [],
117
117
  value: variation.value,