@wix/himalaya-cli 0.1574.0 → 0.1576.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/app-config.d.ts +16 -5
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/define-app.d.ts +6 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/telemetry/wix-bi/src/index.d.ts +15 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/telemetry/wix-bi/src/oneapp.d.ts +90 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bi.mjs +165 -5
- package/dist/cli.mjs +3489 -2601
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/designing-himi-screens.md +7 -1
- package/guides/hhp.md +2 -2
- package/guides/himi-json-output.md +20 -0
- package/guides/push-cli-onboarding.md +10 -0
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +74 -127
- package/templates/beauty-salon/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +18 -5
- package/templates/beauty-salon-owner/dev/index.ts +5 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/branded-lite/dev/index.ts +114 -136
- package/templates/branded-lite/l10n/en.json +570 -0
- package/templates/branded-lite/l10n/he.json +200 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-message-model.ts +1 -1
- package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +3 -2
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +26 -21
- package/templates/branded-lite/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/branded-lite/tier5-src/business-chat/index.ts +184 -63
- package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +18 -5
- package/templates/community-club-owner/dev/index.ts +5 -0
- package/templates/community-club-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/events-meetups-owner/dev/index.ts +3 -0
- package/templates/events-meetups-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/gym-fitness/dev/index.ts +74 -127
- package/templates/gym-fitness/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +18 -5
- package/templates/gym-fitness-owner/dev/index.ts +6 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/nonprofit-cause-owner/dev/index.ts +4 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/owner-lite/dev/index.ts +6 -0
- package/templates/owner-lite/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/owner-lite/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/podcast-creator-owner/dev/index.ts +5 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/portfolio-creative/dev/index.ts +74 -127
- package/templates/portfolio-creative/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +18 -5
- package/templates/portfolio-creative-owner/dev/index.ts +5 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/restaurant-owner/dev/index.ts +6 -0
- package/templates/restaurant-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
- package/templates/retail-store/dev/index.ts +74 -127
- package/templates/retail-store/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +18 -5
- package/templates/retail-store-owner/dev/index.ts +4 -0
- package/templates/retail-store-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +5 -1
- package/templates/yoga-studio-owner/dev/index.ts +4 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/exclusive-writes.ts +66 -0
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +5 -1
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +5 -1
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-service-form/index.ts +5 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
+
|
|
3
|
+
/** Reject repeated taps until the accepted write's result is committed. A promise
|
|
4
|
+
* queue alone releases before the host applies the reducer result, so the next
|
|
5
|
+
* write can still read an old revision/id. The private synchronous reducer below
|
|
6
|
+
* publishes busy and commits completion through the ordinary service handle;
|
|
7
|
+
* awaiting that handle guarantees the host has stored the result before unlock.
|
|
8
|
+
* Skipped actions return undefined, so they cannot roll back the accepted write.
|
|
9
|
+
*/
|
|
10
|
+
export function withExclusiveWrites<S extends object = Record<string, unknown>>(
|
|
11
|
+
registerService: typeof register, names: readonly string[],
|
|
12
|
+
options: { prepare?: (action: string, args: readonly unknown[]) => void; coalesce?: readonly string[]; settle?: (before: S, next: S, live: S) => S } = {},
|
|
13
|
+
): typeof register {
|
|
14
|
+
return ((name: string, definition: ServiceDefinition<Record<string, unknown>>) => {
|
|
15
|
+
let busy = false;
|
|
16
|
+
let activeAction = "";
|
|
17
|
+
let completion = Promise.resolve();
|
|
18
|
+
let complete = () => {};
|
|
19
|
+
let handle: { getState(): Record<string, unknown>; exclusiveWriteState(next: Record<string, unknown>): Promise<unknown> };
|
|
20
|
+
const wrapped: ServiceDefinition<Record<string, unknown>> = {
|
|
21
|
+
...definition,
|
|
22
|
+
state: { ...definition.state, writeBusy: false },
|
|
23
|
+
notHydrated: [...(definition.notHydrated ?? []), 'writeBusy'],
|
|
24
|
+
actions: {
|
|
25
|
+
...Object.fromEntries(Object.entries(definition.actions ?? {}).map(([action, reduce]) => [action,
|
|
26
|
+
names.includes(action) ? async (state: Record<string, unknown>, ...args: never[]) => {
|
|
27
|
+
// Intake request metadata even when its state replacement is skipped.
|
|
28
|
+
options.prepare?.(action, args);
|
|
29
|
+
if (busy) {
|
|
30
|
+
// Repeated initial loads must await the accepted load, so callers
|
|
31
|
+
// observe its committed rows rather than the initial skeleton.
|
|
32
|
+
if (activeAction === action && options.coalesce?.includes(action)) await completion;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
busy = true;
|
|
36
|
+
activeAction = action;
|
|
37
|
+
completion = new Promise<void>(resolve => { complete = resolve; });
|
|
38
|
+
try {
|
|
39
|
+
await handle.exclusiveWriteState({ ...handle.getState(), writeBusy: true });
|
|
40
|
+
const next = await reduce(state, ...args);
|
|
41
|
+
const live = handle.getState();
|
|
42
|
+
// Non-write actions (for example editing a draft field) can still run
|
|
43
|
+
// during I/O. Let the owning service preserve those newer edits.
|
|
44
|
+
const settled = options.settle ? options.settle(state as S, (next ?? live) as S, live as S) : next ?? live;
|
|
45
|
+
await handle.exclusiveWriteState({ ...settled, writeBusy: false });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await handle.exclusiveWriteState({ ...handle.getState(), writeBusy: false });
|
|
48
|
+
throw error;
|
|
49
|
+
} finally {
|
|
50
|
+
busy = false;
|
|
51
|
+
complete();
|
|
52
|
+
}
|
|
53
|
+
} : reduce,
|
|
54
|
+
])),
|
|
55
|
+
exclusiveWriteState: (_state, next: Record<string, unknown>) => next,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const registered = registerService(name, wrapped);
|
|
59
|
+
// Registration preserves this private action, but the dynamic action map
|
|
60
|
+
// cannot express its payload to the generic service handle.
|
|
61
|
+
handle = registered as unknown as typeof handle;
|
|
62
|
+
// Older hosts ignore notHydrated. A fresh worker has no pending write.
|
|
63
|
+
handle.getState().writeBusy = false;
|
|
64
|
+
return registered;
|
|
65
|
+
}) as typeof register;
|
|
66
|
+
}
|
|
@@ -115,6 +115,7 @@ interface State {
|
|
|
115
115
|
checkoutHint: string;
|
|
116
116
|
itemCount: number;
|
|
117
117
|
promoCode: string;
|
|
118
|
+
promoEditRevision: number;
|
|
118
119
|
appliedCode: string;
|
|
119
120
|
appliedLabel: string;
|
|
120
121
|
couponId: string;
|
|
@@ -380,11 +381,24 @@ function couponDetails(result: unknown): { id: string; code: string } {
|
|
|
380
381
|
return { id: coupon?.coupon?.id ?? coupon?.id ?? "", code: coupon?.coupon?.code ?? coupon?.code ?? "" };
|
|
381
382
|
}
|
|
382
383
|
|
|
384
|
+
import { withExclusiveWrites } from "../_shared/exclusive-writes.js";
|
|
385
|
+
|
|
383
386
|
let cartPricing: IapCommerce;
|
|
384
387
|
const defineService = withIapPresentation<State>({
|
|
385
|
-
register: withOnlineWrites(registerState, ["remove", "increment", "decrement", "checkout", "beginPromo", "applyPromo", "removePromo"], { derive, messageFields: action => action.includes("Promo") ? ["promoError"] : ["checkoutHint"] }),
|
|
388
|
+
register: withExclusiveWrites<State>(withOnlineWrites(registerState, ["remove", "increment", "decrement", "checkout", "beginPromo", "applyPromo", "removePromo"], { derive, messageFields: action => action.includes("Promo") ? ["promoError"] : ["checkoutHint"] }), ["load", "remove", "increment", "decrement", "checkout", "applyPromo", "removePromo", "save"], {
|
|
389
|
+
prepare: (action, args) => { if (action === "load") stashCopy(args[0]); },
|
|
390
|
+
coalesce: ["load"],
|
|
391
|
+
// Full cart reloads share the transaction, since they also replace rows and
|
|
392
|
+
// reset promo fields. Only setPromo increments the edit revision: beginPromo's internal error reset
|
|
393
|
+
// must not masquerade as a user edit when a rejected code is retried.
|
|
394
|
+
settle: (before, next, live) => {
|
|
395
|
+
if ((before.promoEditRevision ?? 0) === (live.promoEditRevision ?? 0)) return next;
|
|
396
|
+
return derive({ ...next, promoEditRevision: live.promoEditRevision,
|
|
397
|
+
...(next.items.length ? { promoCode: live.promoCode, promoError: live.promoError } : {}) });
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
386
400
|
onCreate: commerce => { cartPricing = commerce; },
|
|
387
|
-
prefix: "cart", transientKeys: ["items", "subtotal", "promoBusy"], contentActions: ["load", "remove", "increment", "decrement"],
|
|
401
|
+
prefix: "cart", transientKeys: ["items", "subtotal", "promoBusy", "promoEditRevision"], contentActions: ["load", "remove", "increment", "decrement"],
|
|
388
402
|
items: state => state.items.flatMap(row => row.catalogReference?.catalogItemId ? [{ reference: { kind: "storesProduct", id: row.catalogReference.catalogItemId }, commerceDisplayPrice: row.commerceTotal ?? row.total }] : []),
|
|
389
403
|
present: (state, price, allowed) => ({ ...state,
|
|
390
404
|
items: state.items.map(row => ({ ...row, total: row.catalogReference?.catalogItemId ? price(row.catalogReference.catalogItemId, row.commerceTotal ?? row.total) : row.total })),
|
|
@@ -404,10 +418,9 @@ const svc = defineService<State, {
|
|
|
404
418
|
decrement: (state: State, arg?: unknown) => Promise<State>;
|
|
405
419
|
checkout: (state: State) => Promise<State>;
|
|
406
420
|
}>("himiStoreCart", {
|
|
407
|
-
state: derive({ status: "loading", errorKind: "", items: [], subtotal: "", subtotalLabel: "", emptyText: "", checkoutHint: "", showPaidToast: false, noPaidToast: true, itemCount: 0, promoCode: "", appliedCode: "", appliedLabel: "", couponId: "", promoError: "", promoBusy: false, noPromoError: true, hasCoupon: false, discount: "", noDiscount: true, noCheckoutHint: true, noCoupon: true, isLoading: true, notLoading: false, isErrored: false, notErrored: true, noOfflineError: true, noServerError: true, hideContent: true, noEmpty: true, noItems: true }),
|
|
421
|
+
state: derive({ status: "loading", errorKind: "", items: [], subtotal: "", subtotalLabel: "", emptyText: "", checkoutHint: "", showPaidToast: false, noPaidToast: true, itemCount: 0, promoCode: "", promoEditRevision: 0, appliedCode: "", appliedLabel: "", couponId: "", promoError: "", promoBusy: false, noPromoError: true, hasCoupon: false, discount: "", noDiscount: true, noCheckoutHint: true, noCoupon: true, isLoading: true, notLoading: false, isErrored: false, notErrored: true, noOfflineError: true, noServerError: true, hideContent: true, noEmpty: true, noItems: true }),
|
|
408
422
|
actions: {
|
|
409
423
|
load: async (state, arg) => {
|
|
410
|
-
stashCopy(arg);
|
|
411
424
|
// 1) Instant paint from the on-device cache (offline-safe; empty on first-ever run).
|
|
412
425
|
const cached = await readCachedItems();
|
|
413
426
|
const base = cached.length
|
|
@@ -423,7 +436,7 @@ const svc = defineService<State, {
|
|
|
423
436
|
: derive({ ...state, status: "error", errorKind: isOfflineFailure(error) ? "offline" : "server", items: [], subtotal: "", emptyText: copyParam("cart.loadErrorText", "Cart could not load."), checkoutHint: "" });
|
|
424
437
|
}
|
|
425
438
|
},
|
|
426
|
-
setPromo: (state, arg) => derive({ ...state, promoCode: (typeof arg === "string" ? arg : String((arg as { value?: unknown } | undefined)?.value ?? "")).slice(0, 100), promoError: "" }),
|
|
439
|
+
setPromo: (state, arg) => derive({ ...state, promoEditRevision: (state.promoEditRevision ?? 0) + 1, promoCode: (typeof arg === "string" ? arg : String((arg as { value?: unknown } | undefined)?.value ?? "")).slice(0, 100), promoError: "" }),
|
|
427
440
|
beginPromo: state => derive({ ...state, promoBusy: true, promoError: "" }),
|
|
428
441
|
applyPromo: async (state): Promise<State> => {
|
|
429
442
|
const code = state.promoCode.trim();
|
|
@@ -79742,6 +79742,7 @@ const SCREENS = runtimeSetting({
|
|
|
79742
79742
|
"title": "Create product",
|
|
79743
79743
|
"style": "primary",
|
|
79744
79744
|
"fullWidth": true,
|
|
79745
|
+
"disabledPath": "$service:himiOwnerLiteProductForm.writeBusy",
|
|
79745
79746
|
"action": {
|
|
79746
79747
|
"actionId": "tier5.himiOwnerLiteProductForm.submit"
|
|
79747
79748
|
}
|
|
@@ -80061,6 +80062,7 @@ const SCREENS = runtimeSetting({
|
|
|
80061
80062
|
"title": "Create post",
|
|
80062
80063
|
"style": "primary",
|
|
80063
80064
|
"fullWidth": true,
|
|
80065
|
+
"disabledPath": "$service:himiOwnerLitePostForm.writeBusy",
|
|
80064
80066
|
"action": {
|
|
80065
80067
|
"actionId": "tier5.himiOwnerLitePostForm.submit"
|
|
80066
80068
|
}
|
|
@@ -80576,6 +80578,7 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
80576
80578
|
"title": "Save changes",
|
|
80577
80579
|
"style": "primary",
|
|
80578
80580
|
"fullWidth": true,
|
|
80581
|
+
"disabledPath": "$service:himiOwnerLiteProductForm.writeBusy",
|
|
80579
80582
|
"action": {
|
|
80580
80583
|
"actionId": "tier5.himiOwnerLiteProductForm.submit"
|
|
80581
80584
|
}
|
|
@@ -80823,6 +80826,7 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
80823
80826
|
"title": "Save changes",
|
|
80824
80827
|
"style": "primary",
|
|
80825
80828
|
"fullWidth": true,
|
|
80829
|
+
"disabledPath": "$service:himiOwnerLiteServiceForm.writeBusy",
|
|
80826
80830
|
"action": {
|
|
80827
80831
|
"actionId": "tier5.himiOwnerLiteServiceForm.submit"
|
|
80828
80832
|
}
|
|
@@ -81146,6 +81150,7 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
81146
81150
|
"title": "Save changes",
|
|
81147
81151
|
"style": "primary",
|
|
81148
81152
|
"fullWidth": true,
|
|
81153
|
+
"disabledPath": "$service:himiOwnerLitePostForm.writeBusy",
|
|
81149
81154
|
"action": {
|
|
81150
81155
|
"actionId": "tier5.himiOwnerLitePostForm.submit"
|
|
81151
81156
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
+
|
|
3
|
+
/** Reject repeated taps until the accepted write's result is committed. A promise
|
|
4
|
+
* queue alone releases before the host applies the reducer result, so the next
|
|
5
|
+
* write can still read an old revision/id. The private synchronous reducer below
|
|
6
|
+
* publishes busy and commits completion through the ordinary service handle;
|
|
7
|
+
* awaiting that handle guarantees the host has stored the result before unlock.
|
|
8
|
+
* Skipped actions return undefined, so they cannot roll back the accepted write.
|
|
9
|
+
*/
|
|
10
|
+
export function withExclusiveWrites<S extends object = Record<string, unknown>>(
|
|
11
|
+
registerService: typeof register, names: readonly string[],
|
|
12
|
+
options: { prepare?: (action: string, args: readonly unknown[]) => void; coalesce?: readonly string[]; settle?: (before: S, next: S, live: S) => S } = {},
|
|
13
|
+
): typeof register {
|
|
14
|
+
return ((name: string, definition: ServiceDefinition<Record<string, unknown>>) => {
|
|
15
|
+
let busy = false;
|
|
16
|
+
let activeAction = "";
|
|
17
|
+
let completion = Promise.resolve();
|
|
18
|
+
let complete = () => {};
|
|
19
|
+
let handle: { getState(): Record<string, unknown>; exclusiveWriteState(next: Record<string, unknown>): Promise<unknown> };
|
|
20
|
+
const wrapped: ServiceDefinition<Record<string, unknown>> = {
|
|
21
|
+
...definition,
|
|
22
|
+
state: { ...definition.state, writeBusy: false },
|
|
23
|
+
notHydrated: [...(definition.notHydrated ?? []), 'writeBusy'],
|
|
24
|
+
actions: {
|
|
25
|
+
...Object.fromEntries(Object.entries(definition.actions ?? {}).map(([action, reduce]) => [action,
|
|
26
|
+
names.includes(action) ? async (state: Record<string, unknown>, ...args: never[]) => {
|
|
27
|
+
// Intake request metadata even when its state replacement is skipped.
|
|
28
|
+
options.prepare?.(action, args);
|
|
29
|
+
if (busy) {
|
|
30
|
+
// Repeated initial loads must await the accepted load, so callers
|
|
31
|
+
// observe its committed rows rather than the initial skeleton.
|
|
32
|
+
if (activeAction === action && options.coalesce?.includes(action)) await completion;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
busy = true;
|
|
36
|
+
activeAction = action;
|
|
37
|
+
completion = new Promise<void>(resolve => { complete = resolve; });
|
|
38
|
+
try {
|
|
39
|
+
await handle.exclusiveWriteState({ ...handle.getState(), writeBusy: true });
|
|
40
|
+
const next = await reduce(state, ...args);
|
|
41
|
+
const live = handle.getState();
|
|
42
|
+
// Non-write actions (for example editing a draft field) can still run
|
|
43
|
+
// during I/O. Let the owning service preserve those newer edits.
|
|
44
|
+
const settled = options.settle ? options.settle(state as S, (next ?? live) as S, live as S) : next ?? live;
|
|
45
|
+
await handle.exclusiveWriteState({ ...settled, writeBusy: false });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await handle.exclusiveWriteState({ ...handle.getState(), writeBusy: false });
|
|
48
|
+
throw error;
|
|
49
|
+
} finally {
|
|
50
|
+
busy = false;
|
|
51
|
+
complete();
|
|
52
|
+
}
|
|
53
|
+
} : reduce,
|
|
54
|
+
])),
|
|
55
|
+
exclusiveWriteState: (_state, next: Record<string, unknown>) => next,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const registered = registerService(name, wrapped);
|
|
59
|
+
// Registration preserves this private action, but the dynamic action map
|
|
60
|
+
// cannot express its payload to the generic service handle.
|
|
61
|
+
handle = registered as unknown as typeof handle;
|
|
62
|
+
// Older hosts ignore notHydrated. A fresh worker has no pending write.
|
|
63
|
+
handle.getState().writeBusy = false;
|
|
64
|
+
return registered;
|
|
65
|
+
}) as typeof register;
|
|
66
|
+
}
|
|
@@ -8,7 +8,9 @@ import { editorSubmitValue, queuedDocument, editorChanged, editorRunKeys, resetH
|
|
|
8
8
|
import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
|
|
9
9
|
import { wixCall } from "@himalaya/wix-site-media";
|
|
10
10
|
import { wixMediaUrl, WIX_DETAIL_IMAGE } from "../_shared/himi-store.js";
|
|
11
|
-
import { defineService } from "@himalaya/state";
|
|
11
|
+
import { defineService as registerService } from "@himalaya/state";
|
|
12
|
+
import { withExclusiveWrites } from "../_shared/exclusive-writes.js";
|
|
13
|
+
const defineService = withExclusiveWrites(registerService, ["submit"]);
|
|
12
14
|
import { getDraftPost, createDraftPost, updateDraftPost, type WixDraftPostWrite, type WixRichContent } from "@himalaya/wix-blog";
|
|
13
15
|
import { ownerLoadError, isOwnerPreviewDemo, ownerDemoFallback, ownerWriteError } from "../_shared/owner-load-error.js";
|
|
14
16
|
import { DEMO_POSTS, DEMO_NOTE, DEMO_SAVE_NOTE, demoRefusalPatch } from "../_shared/owner-preview-demo.js";
|
|
@@ -53,6 +55,7 @@ interface PostFormState {
|
|
|
53
55
|
noCover: boolean;
|
|
54
56
|
isFeatured: string;
|
|
55
57
|
status: "idle" | "loading" | "saving" | "saved" | "error";
|
|
58
|
+
writeBusy: boolean;
|
|
56
59
|
error: string;
|
|
57
60
|
savedMessage: string;
|
|
58
61
|
/** WS-C — canonical detail target (`himi_owner_lite_post`); `savedId` rides
|
|
@@ -128,6 +131,7 @@ function shape(s: Partial<PostFormState>): PostFormState {
|
|
|
128
131
|
noCover: !s.coverUrl,
|
|
129
132
|
isFeatured: s.isFeatured ?? "false",
|
|
130
133
|
status: s.status ?? "idle",
|
|
134
|
+
writeBusy: s.writeBusy ?? false,
|
|
131
135
|
error: s.error ?? "",
|
|
132
136
|
savedMessage: s.savedMessage ?? "",
|
|
133
137
|
// WS-C — canonical detail target + the saved entity's id as a separate nav
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// the WRITE shape needs `priceData: { price: <number>, currency }`. Bound by the
|
|
7
7
|
// generic buildForm descriptor ($service:himiOwnerLiteProductForm.*).
|
|
8
8
|
|
|
9
|
-
import { defineService } from "@himalaya/state";
|
|
9
|
+
import { defineService as registerService } from "@himalaya/state";
|
|
10
|
+
import { withExclusiveWrites } from "../_shared/exclusive-writes.js";
|
|
11
|
+
const defineService = withExclusiveWrites(registerService, ["submit"]);
|
|
10
12
|
import {
|
|
11
13
|
getProduct,
|
|
12
14
|
createProduct,
|
|
@@ -76,6 +78,7 @@ interface ProductFormState {
|
|
|
76
78
|
descriptionNote: string;
|
|
77
79
|
visible: string;
|
|
78
80
|
status: "idle" | "loading" | "saving" | "saved" | "error";
|
|
81
|
+
writeBusy: boolean;
|
|
79
82
|
error: string;
|
|
80
83
|
savedMessage: string;
|
|
81
84
|
/** WS-C — canonical detail target (`himi_owner_lite_product`); `savedId` rides
|
|
@@ -161,6 +164,7 @@ function shape(s: Partial<ProductFormState>): ProductFormState {
|
|
|
161
164
|
descriptionNote: dMode === "locked" ? copyParam("ownerProductForm.richDescriptionNote", "This description has formatting or media. Edit it in your site dashboard to keep that content intact; name, price and SKU can be changed here.") : "",
|
|
162
165
|
visible: s.visible ?? "true",
|
|
163
166
|
status: s.status ?? "idle",
|
|
167
|
+
writeBusy: s.writeBusy ?? false,
|
|
164
168
|
error: s.error ?? "",
|
|
165
169
|
savedMessage: s.savedMessage ?? "",
|
|
166
170
|
// WS-C — canonical detail target + the saved entity's id as a separate nav
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// updateService. Bound by buildForm ($service:himiOwnerLiteServiceForm.*).
|
|
6
6
|
|
|
7
7
|
import { landsOnLive } from "../_shared/owner-editor-value.js";
|
|
8
|
-
import { defineService } from "@himalaya/state";
|
|
8
|
+
import { defineService as registerService } from "@himalaya/state";
|
|
9
|
+
import { withExclusiveWrites } from "../_shared/exclusive-writes.js";
|
|
10
|
+
const defineService = withExclusiveWrites(registerService, ["submit"]);
|
|
9
11
|
import { getService, updateService, type WixService } from "@himalaya/wix-bookings";
|
|
10
12
|
import { ownerLoadError, isOwnerPreviewDemo, ownerDemoFallback, ownerWriteError } from "../_shared/owner-load-error.js";
|
|
11
13
|
import { DEMO_SERVICES, DEMO_NOTE, DEMO_SAVE_NOTE, demoRefusalPatch } from "../_shared/owner-preview-demo.js";
|
|
@@ -31,6 +33,7 @@ interface ServiceFormState {
|
|
|
31
33
|
description: string;
|
|
32
34
|
category: string;
|
|
33
35
|
status: "idle" | "loading" | "saving" | "saved" | "error";
|
|
36
|
+
writeBusy: boolean;
|
|
34
37
|
error: string;
|
|
35
38
|
savedMessage: string;
|
|
36
39
|
/** WS-C — canonical detail target (`himi_owner_lite_service`); `savedId` rides
|
|
@@ -88,6 +91,7 @@ function shape(s: Partial<ServiceFormState>): ServiceFormState {
|
|
|
88
91
|
description: s.description ?? "",
|
|
89
92
|
category: s.category ?? "",
|
|
90
93
|
status: s.status ?? "idle",
|
|
94
|
+
writeBusy: s.writeBusy ?? false,
|
|
91
95
|
error: s.error ?? "",
|
|
92
96
|
savedMessage: s.savedMessage ?? "",
|
|
93
97
|
// WS-C — canonical detail target + the saved entity's id as a separate nav
|
|
@@ -82378,6 +82378,7 @@ const SCREENS = runtimeSetting({
|
|
|
82378
82378
|
"title": "Create product",
|
|
82379
82379
|
"style": "primary",
|
|
82380
82380
|
"fullWidth": true,
|
|
82381
|
+
"disabledPath": "$service:himiOwnerLiteProductForm.writeBusy",
|
|
82381
82382
|
"action": {
|
|
82382
82383
|
"actionId": "tier5.himiOwnerLiteProductForm.submit"
|
|
82383
82384
|
}
|
|
@@ -82698,6 +82699,7 @@ const SCREENS = runtimeSetting({
|
|
|
82698
82699
|
"title": "Create post",
|
|
82699
82700
|
"style": "primary",
|
|
82700
82701
|
"fullWidth": true,
|
|
82702
|
+
"disabledPath": "$service:himiOwnerLitePostForm.writeBusy",
|
|
82701
82703
|
"action": {
|
|
82702
82704
|
"actionId": "tier5.himiOwnerLitePostForm.submit"
|
|
82703
82705
|
}
|
|
@@ -83213,6 +83215,7 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
83213
83215
|
"title": "Save changes",
|
|
83214
83216
|
"style": "primary",
|
|
83215
83217
|
"fullWidth": true,
|
|
83218
|
+
"disabledPath": "$service:himiOwnerLiteProductForm.writeBusy",
|
|
83216
83219
|
"action": {
|
|
83217
83220
|
"actionId": "tier5.himiOwnerLiteProductForm.submit"
|
|
83218
83221
|
}
|
|
@@ -83461,6 +83464,7 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
83461
83464
|
"title": "Save changes",
|
|
83462
83465
|
"style": "primary",
|
|
83463
83466
|
"fullWidth": true,
|
|
83467
|
+
"disabledPath": "$service:himiOwnerLiteServiceForm.writeBusy",
|
|
83464
83468
|
"action": {
|
|
83465
83469
|
"actionId": "tier5.himiOwnerLiteServiceForm.submit"
|
|
83466
83470
|
}
|
|
@@ -83823,6 +83827,7 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
83823
83827
|
"title": "Save changes",
|
|
83824
83828
|
"style": "primary",
|
|
83825
83829
|
"fullWidth": true,
|
|
83830
|
+
"disabledPath": "$service:himiOwnerLiteEventForm.writeBusy",
|
|
83826
83831
|
"action": {
|
|
83827
83832
|
"actionId": "tier5.himiOwnerLiteEventForm.submit"
|
|
83828
83833
|
}
|
|
@@ -84146,6 +84151,7 @@ const TEMPLATED: { pattern: string; display: string; liveSeed?: LiveSeed; static
|
|
|
84146
84151
|
"title": "Save changes",
|
|
84147
84152
|
"style": "primary",
|
|
84148
84153
|
"fullWidth": true,
|
|
84154
|
+
"disabledPath": "$service:himiOwnerLitePostForm.writeBusy",
|
|
84149
84155
|
"action": {
|
|
84150
84156
|
"actionId": "tier5.himiOwnerLitePostForm.submit"
|
|
84151
84157
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
+
|
|
3
|
+
/** Reject repeated taps until the accepted write's result is committed. A promise
|
|
4
|
+
* queue alone releases before the host applies the reducer result, so the next
|
|
5
|
+
* write can still read an old revision/id. The private synchronous reducer below
|
|
6
|
+
* publishes busy and commits completion through the ordinary service handle;
|
|
7
|
+
* awaiting that handle guarantees the host has stored the result before unlock.
|
|
8
|
+
* Skipped actions return undefined, so they cannot roll back the accepted write.
|
|
9
|
+
*/
|
|
10
|
+
export function withExclusiveWrites<S extends object = Record<string, unknown>>(
|
|
11
|
+
registerService: typeof register, names: readonly string[],
|
|
12
|
+
options: { prepare?: (action: string, args: readonly unknown[]) => void; coalesce?: readonly string[]; settle?: (before: S, next: S, live: S) => S } = {},
|
|
13
|
+
): typeof register {
|
|
14
|
+
return ((name: string, definition: ServiceDefinition<Record<string, unknown>>) => {
|
|
15
|
+
let busy = false;
|
|
16
|
+
let activeAction = "";
|
|
17
|
+
let completion = Promise.resolve();
|
|
18
|
+
let complete = () => {};
|
|
19
|
+
let handle: { getState(): Record<string, unknown>; exclusiveWriteState(next: Record<string, unknown>): Promise<unknown> };
|
|
20
|
+
const wrapped: ServiceDefinition<Record<string, unknown>> = {
|
|
21
|
+
...definition,
|
|
22
|
+
state: { ...definition.state, writeBusy: false },
|
|
23
|
+
notHydrated: [...(definition.notHydrated ?? []), 'writeBusy'],
|
|
24
|
+
actions: {
|
|
25
|
+
...Object.fromEntries(Object.entries(definition.actions ?? {}).map(([action, reduce]) => [action,
|
|
26
|
+
names.includes(action) ? async (state: Record<string, unknown>, ...args: never[]) => {
|
|
27
|
+
// Intake request metadata even when its state replacement is skipped.
|
|
28
|
+
options.prepare?.(action, args);
|
|
29
|
+
if (busy) {
|
|
30
|
+
// Repeated initial loads must await the accepted load, so callers
|
|
31
|
+
// observe its committed rows rather than the initial skeleton.
|
|
32
|
+
if (activeAction === action && options.coalesce?.includes(action)) await completion;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
busy = true;
|
|
36
|
+
activeAction = action;
|
|
37
|
+
completion = new Promise<void>(resolve => { complete = resolve; });
|
|
38
|
+
try {
|
|
39
|
+
await handle.exclusiveWriteState({ ...handle.getState(), writeBusy: true });
|
|
40
|
+
const next = await reduce(state, ...args);
|
|
41
|
+
const live = handle.getState();
|
|
42
|
+
// Non-write actions (for example editing a draft field) can still run
|
|
43
|
+
// during I/O. Let the owning service preserve those newer edits.
|
|
44
|
+
const settled = options.settle ? options.settle(state as S, (next ?? live) as S, live as S) : next ?? live;
|
|
45
|
+
await handle.exclusiveWriteState({ ...settled, writeBusy: false });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await handle.exclusiveWriteState({ ...handle.getState(), writeBusy: false });
|
|
48
|
+
throw error;
|
|
49
|
+
} finally {
|
|
50
|
+
busy = false;
|
|
51
|
+
complete();
|
|
52
|
+
}
|
|
53
|
+
} : reduce,
|
|
54
|
+
])),
|
|
55
|
+
exclusiveWriteState: (_state, next: Record<string, unknown>) => next,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const registered = registerService(name, wrapped);
|
|
59
|
+
// Registration preserves this private action, but the dynamic action map
|
|
60
|
+
// cannot express its payload to the generic service handle.
|
|
61
|
+
handle = registered as unknown as typeof handle;
|
|
62
|
+
// Older hosts ignore notHydrated. A fresh worker has no pending write.
|
|
63
|
+
handle.getState().writeBusy = false;
|
|
64
|
+
return registered;
|
|
65
|
+
}) as typeof register;
|
|
66
|
+
}
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// form can't safely collect — so this form only edits an existing event's text
|
|
7
7
|
// (title + description). Bound by buildForm ($service:himiOwnerLiteEventForm.*).
|
|
8
8
|
|
|
9
|
-
import { defineService } from "@himalaya/state";
|
|
9
|
+
import { defineService as registerService } from "@himalaya/state";
|
|
10
|
+
import { withExclusiveWrites } from "../_shared/exclusive-writes.js";
|
|
11
|
+
const defineService = withExclusiveWrites(registerService, ["submit"]);
|
|
10
12
|
import { getEvent, updateEvent, type WixEvent } from "@himalaya/wix-events";
|
|
11
13
|
import { normalizeEvent } from "../_shared/himi-events.js";
|
|
12
14
|
import { postBodyText, plainPostBody } from "../_shared/owner-post-content.js";
|
|
@@ -66,6 +68,7 @@ interface EventFormState {
|
|
|
66
68
|
when: string;
|
|
67
69
|
location: string;
|
|
68
70
|
status: "idle" | "loading" | "saving" | "saved" | "error";
|
|
71
|
+
writeBusy: boolean;
|
|
69
72
|
error: string;
|
|
70
73
|
savedMessage: string;
|
|
71
74
|
/** WS-C — canonical detail target (`himi_owner_lite_event`); `savedId` rides
|
|
@@ -140,6 +143,7 @@ function shape(s: Partial<EventFormState>): EventFormState {
|
|
|
140
143
|
when: s.when ?? "—",
|
|
141
144
|
location: s.location ?? "—",
|
|
142
145
|
status: s.status ?? "idle",
|
|
146
|
+
writeBusy: s.writeBusy ?? false,
|
|
143
147
|
error: s.error ?? "",
|
|
144
148
|
savedMessage: s.savedMessage ?? "",
|
|
145
149
|
// WS-C — canonical detail target + the saved entity's id as a separate nav
|
|
@@ -8,7 +8,9 @@ import { editorSubmitValue, queuedDocument, editorChanged, editorRunKeys, resetH
|
|
|
8
8
|
import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
|
|
9
9
|
import { wixCall } from "@himalaya/wix-site-media";
|
|
10
10
|
import { wixMediaUrl, WIX_DETAIL_IMAGE } from "../_shared/himi-store.js";
|
|
11
|
-
import { defineService } from "@himalaya/state";
|
|
11
|
+
import { defineService as registerService } from "@himalaya/state";
|
|
12
|
+
import { withExclusiveWrites } from "../_shared/exclusive-writes.js";
|
|
13
|
+
const defineService = withExclusiveWrites(registerService, ["submit"]);
|
|
12
14
|
import { getDraftPost, createDraftPost, updateDraftPost, type WixDraftPostWrite, type WixRichContent } from "@himalaya/wix-blog";
|
|
13
15
|
import { ownerLoadError, isOwnerPreviewDemo, ownerDemoFallback, ownerWriteError } from "../_shared/owner-load-error.js";
|
|
14
16
|
import { DEMO_POSTS, DEMO_NOTE, DEMO_SAVE_NOTE, demoRefusalPatch } from "../_shared/owner-preview-demo.js";
|
|
@@ -53,6 +55,7 @@ interface PostFormState {
|
|
|
53
55
|
noCover: boolean;
|
|
54
56
|
isFeatured: string;
|
|
55
57
|
status: "idle" | "loading" | "saving" | "saved" | "error";
|
|
58
|
+
writeBusy: boolean;
|
|
56
59
|
error: string;
|
|
57
60
|
savedMessage: string;
|
|
58
61
|
/** WS-C — canonical detail target (`himi_owner_lite_post`); `savedId` rides
|
|
@@ -128,6 +131,7 @@ function shape(s: Partial<PostFormState>): PostFormState {
|
|
|
128
131
|
noCover: !s.coverUrl,
|
|
129
132
|
isFeatured: s.isFeatured ?? "false",
|
|
130
133
|
status: s.status ?? "idle",
|
|
134
|
+
writeBusy: s.writeBusy ?? false,
|
|
131
135
|
error: s.error ?? "",
|
|
132
136
|
savedMessage: s.savedMessage ?? "",
|
|
133
137
|
// WS-C — canonical detail target + the saved entity's id as a separate nav
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// the WRITE shape needs `priceData: { price: <number>, currency }`. Bound by the
|
|
7
7
|
// generic buildForm descriptor ($service:himiOwnerLiteProductForm.*).
|
|
8
8
|
|
|
9
|
-
import { defineService } from "@himalaya/state";
|
|
9
|
+
import { defineService as registerService } from "@himalaya/state";
|
|
10
|
+
import { withExclusiveWrites } from "../_shared/exclusive-writes.js";
|
|
11
|
+
const defineService = withExclusiveWrites(registerService, ["submit"]);
|
|
10
12
|
import {
|
|
11
13
|
getProduct,
|
|
12
14
|
createProduct,
|
|
@@ -76,6 +78,7 @@ interface ProductFormState {
|
|
|
76
78
|
descriptionNote: string;
|
|
77
79
|
visible: string;
|
|
78
80
|
status: "idle" | "loading" | "saving" | "saved" | "error";
|
|
81
|
+
writeBusy: boolean;
|
|
79
82
|
error: string;
|
|
80
83
|
savedMessage: string;
|
|
81
84
|
/** WS-C — canonical detail target (`himi_owner_lite_product`); `savedId` rides
|
|
@@ -161,6 +164,7 @@ function shape(s: Partial<ProductFormState>): ProductFormState {
|
|
|
161
164
|
descriptionNote: dMode === "locked" ? copyParam("ownerProductForm.richDescriptionNote", "This description has formatting or media. Edit it in your site dashboard to keep that content intact; name, price and SKU can be changed here.") : "",
|
|
162
165
|
visible: s.visible ?? "true",
|
|
163
166
|
status: s.status ?? "idle",
|
|
167
|
+
writeBusy: s.writeBusy ?? false,
|
|
164
168
|
error: s.error ?? "",
|
|
165
169
|
savedMessage: s.savedMessage ?? "",
|
|
166
170
|
// WS-C — canonical detail target + the saved entity's id as a separate nav
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// updateService. Bound by buildForm ($service:himiOwnerLiteServiceForm.*).
|
|
6
6
|
|
|
7
7
|
import { landsOnLive } from "../_shared/owner-editor-value.js";
|
|
8
|
-
import { defineService } from "@himalaya/state";
|
|
8
|
+
import { defineService as registerService } from "@himalaya/state";
|
|
9
|
+
import { withExclusiveWrites } from "../_shared/exclusive-writes.js";
|
|
10
|
+
const defineService = withExclusiveWrites(registerService, ["submit"]);
|
|
9
11
|
import { getService, updateService, type WixService } from "@himalaya/wix-bookings";
|
|
10
12
|
import { ownerLoadError, isOwnerPreviewDemo, ownerDemoFallback, ownerWriteError } from "../_shared/owner-load-error.js";
|
|
11
13
|
import { DEMO_SERVICES, DEMO_NOTE, DEMO_SAVE_NOTE, demoRefusalPatch } from "../_shared/owner-preview-demo.js";
|
|
@@ -31,6 +33,7 @@ interface ServiceFormState {
|
|
|
31
33
|
description: string;
|
|
32
34
|
category: string;
|
|
33
35
|
status: "idle" | "loading" | "saving" | "saved" | "error";
|
|
36
|
+
writeBusy: boolean;
|
|
34
37
|
error: string;
|
|
35
38
|
savedMessage: string;
|
|
36
39
|
/** WS-C — canonical detail target (`himi_owner_lite_service`); `savedId` rides
|
|
@@ -88,6 +91,7 @@ function shape(s: Partial<ServiceFormState>): ServiceFormState {
|
|
|
88
91
|
description: s.description ?? "",
|
|
89
92
|
category: s.category ?? "",
|
|
90
93
|
status: s.status ?? "idle",
|
|
94
|
+
writeBusy: s.writeBusy ?? false,
|
|
91
95
|
error: s.error ?? "",
|
|
92
96
|
savedMessage: s.savedMessage ?? "",
|
|
93
97
|
// WS-C — canonical detail target + the saved entity's id as a separate nav
|