Tally UI

Changelog

Released changes to the @tallyui packages, newest first.

3.0.0

@tallyui/core

Major Changes

  • 668f71f: Breaking: precheckCommand now takes the server's own supported versions as a required second argument, { orderCreate, register }, and checks against them; its unsupported_version message and data name the server's list, not core's (#297). Plugins MUST pass their supported versions: precheckCommand(envelope, { orderCreate: [1, 2, 3], register: [1] }); an empty list throws a TypeError. This is part of 3.0.0's breaking changes and adds no extra major. SUPPORTED_ORDER_CREATE_VERSIONS and SUPPORTED_REGISTER_VERSIONS stay exported as the till's capability (what toOrderCreateEnvelope can produce), not what a server supports.

Minor Changes

  • 4de75c2: A batch over 50 commands is answered 413 with { code: 'batch_too_large', maxCommands: 50, message: 'At most 50 commands are allowed' }, never 400 (ADR-038, Front desk ruling 18): @tallyui/core/server's validateBatch returns that body on its 413 failure so plugins send it as is. @tallyui/core/server now also exports MAX_COMMANDS_PER_BATCH, and @tallyui/core and @tallyui/core/server export the BatchTooLargeBody type.

  • 894b6ae: One catalogue reconcile runner replaces the id and fingerprint reconcile runners (#248, part A). startIdReconcile and startFingerprintReconcile remain as thin wrappers with their options and results.

    • startCatalogueReconcile (new) compares the backend's product listing with the till in one pass and hands what differs to the collection's pull. It:
      • stays within a request budget (30 a minute by default) instead of a page cap, so large catalogues never truncate;
      • keeps its daily gate and a resume cursor in RxDB local documents, so it does not run on every start, and it resumes after an interruption;
      • deletes only in an uninterrupted pass, and only what the connector confirms gone. The mass-delete brake is checked on the candidates before the connector is asked, and the connector is asked in chunks (confirmChunk, default 100), each within the budget;
      • stops or skips by errorKind;
      • logs what it did through an optional log callback.
    • Behaviour changes for the existing runners:
      • there is no pass 5 s after every start: the daily gate is checked at the start delay and then hourly;
      • maxPages is ignored;
      • requests are paced by the budget;
      • differences are refetched page by page;
      • apps that run more than one runner on the same collection pass a distinct stateId.
    • createReconcileFeed:
      • it takes an optional key to match fetched documents by the local primary key, so it works where doc.id is not the primary key; fetchByIds then receives the queued entries;
      • a tombstone entry is deleted without a fetch;
      • a fetched document keeps a _deleted the connector set.
    • @tallyui/core adds CatalogueReconcileAdapter and TallyConnector.reconcile.catalogue.
    • Connector collections enable RxDB local documents.
  • 04905ef: Catalogue now applies the provider's reconciled stock overlay itself (tiles, search and the variant chooser agree), and useStockOverlaid and useStockOverlayAsOf are new.

  • faa7cda: Expose ConnectorUnauthorizedError for expired or rejected stored credentials in Vendure and Medusa requests.

  • 9f34416: @tallyui/core/server adds the batch envelope check, the per-command version pre-check, the supported versions, totalWarnings and parseCommandResult (throwing CommandResultError), byte-identical to the medusapos plugin's.

  • fb57e1d: @tallyui/core/server adds the register payload check (registerPayloadErrors), the expected-cash derivation (deriveSessionFigures, deriveVariance) and the register conflict codes (RegisterConflictCode, RegisterOutcome), byte-identical to the medusapos plugin's.

  • 898e98b: Add @tallyui/core/server, the plugins' shared contract: money, fingerprint, the order.create payload shape and fiscal figures, byte-identical to the medusapos plugin's.

  • 75c5dce: @tallyui/core/server's envelope types now admit register commands and any validated version, and it exports BatchOutcome with inProgressOutcome and transientOutcome. It also exports CommandRejectionCode and platformErrorResult (a new platform_error code).

  • ba63f04: CommandWarning gains { code: 'customer_ignored'; customerId: string } (#266): a sale whose customerId doesn't resolve is kept as a guest sale. knownWarnings keeps it and parseCommandResult accepts it when customerId is 1 to 64 characters; the orders list renders it.

  • 0d04d13: Add neutral Customer, CustomerInput and CustomerServiceError exports and optional online-only customer search, create and get connector methods.

    Implement customer search, create and get for Medusa's admin-user connector.

  • 78d324e: Add useSale.setCustomer and ReceiptData.header.customer, customerTraits, CustomerPicker, generic CustomerSelect/CustomerCard with traits overrides, CustomerForm.showAddress, and the receipt's customer line.

    CustomerSelect rows are now pressable, so choosing a result works on the web (it previously did nothing).

  • 7fee0c1: A WooCommerce product whose uuid changed in the store is replaced on the till in one pass (#331). The till delivers it under its new uuid and removes the old copy. Before this fix, it removed the old copy and dropped the new one, so the product was missing until the next daily check, and it was removed without the usual by-id check.

    • The reconcile feed: when an entry that has a local copy is fetched back under a different primary key but the same remote id, the feed delivers that document as well as removing the old copy.
    • combinePullAdapters takes an optional key for resolving duplicates across its sub-adapters. It defaults to doc.id, as before. WooCommerce passes the uuid (its primary key): two documents that share a store id, such as a product's new copy and its old copy's removal, must both reach the collection.
  • 24b74fd: CommandWarning gains { code: 'figures_mismatch'; fields: Array<{ field: 'subtotalMinor' | 'taxMinor' | 'discountMinor' | (string & {}); tillMinor: number; serverMinor: number }> } (#257): one warning per sale listing each of the till's figures that differs from the server's own computation. parseCommandResult accepts it when fields is non-empty, each field is one of the three names with none repeated, and each entry's two values are different safe integers. knownWarnings applies the same rules but keeps a field name it doesn't know (any non-empty string), since a newer store may send one; the orders list renders it, an unknown field by its raw name.

  • eb5a032: A /tally/v1/info answer that says nothing about the store no longer means the default tax rounding (a follow-up to #339).

    • Unknown: a 2xx that is not JSON, and a taxRounding value that is present but malformed, now read as "unknown" (undefined), like a network failure or a 5xx. The till's store settings wait and retry instead of selling on a guessed rounding.
    • Unchanged: a 404 still means an older plugin (orderCreate: 1, the default rounding), and so does a well-formed body with no taxRounding key.
    • Type change: parseInfoCapabilities now returns ServerCapabilities | undefined. It is undefined when the body carries a malformed taxRounding.
  • 54ee98a: @tallyui/core/server adds the internal_error rejection code and internalErrorResult.

  • 27d736e: CommandWarning gains bridgeMinor on total_mismatch and a new tax_rate_mismatch code, and the till now ignores warning codes it doesn't know (knownWarnings), instead of showing them as a store total.

  • e59ebec: Each line is taxed at its product's tax class, not the store's default (#288). ProductTraits gains an optional getTaxClass(doc, variantId?), the backend's tax class id, a key of StoreSettings.taxRatesPpm. addProduct and addEntryToCart pass it to addLine through the new AddLineInput.taxClass, which the tax context resolves; a connector without the accessor is unchanged (the default rate). TaxProvider taxes a class with no rate at the default rate and warns once per class through the new taxLogger. connector-vendure replicates each variant's taxCategory { id } and implements the accessor, and its store settings give every tax category with no enabled rate in the default zone an explicit 0 rate, as Vendure charges; its product schema goes to version 2, so the products collection is dropped and downloaded again on the first sync after the upgrade.

  • 2ecaa36: A replication adapter can set pull.batchSize, and the Medusa connector pulls 500 products per page.

  • 901fa66: Add order.create envelope version 3, its display and tax-rate wire types, and the payload's sessionId and customer reference.

    At capability 3, finalizeOrder copies the receipt's display and taxByRate into the sale. Version 3 sends those figures and the sale's session (stamped or late) as sessionId. Older orders keep their existing envelope version.

    Move pos_orders to schema version 3 with a sessionId index and the optional sentVersion and downgradedFrom fields (declared for the outbox's version fallback, not yet written). Apps must open pos_orders with addPosOrderCollection, which migrates it.

  • bf2d805: order.create version 4 (#286): every discountMinor, the order's and each line's, is tax-exclusive, so their sum still holds. Core accepts version 4 with version 3's fields. The till's envelope builder (toOrderCreateEnvelope) produces version 4 when capped at 4 or more and the order's sentVersion doesn't hold it lower. The till doesn't send version 4 yet: the outbox doesn't pass the server's max, so it still sends version 3 or lower with the same figures, byte-identical.

  • ca0beac: Fall back to the server's supported order.create version while preserving stored fiscal figures and command IDs. Record the sent version and downgrade in the order audit, and expose command error details.

  • af623c9: The order.create string lengths move from payloadShapeErrors to the new payloadBoundErrors, which precheckCommand calls after the replay lookup, so an applied order resent with a long title replays as duplicate; payloadShapeErrors keeps the types, the customerId and sessionId bounds and the NUL check. useSale applies a tender before logging a dropped reference, and add() refuses a product whose id or v3 tax code finalize would refuse; the tender's reference field caps at 255 characters. finalizeOrder now freezes the sent form (names and discount labels cut, an unsendable customer email or id left out) and toOrderCreateEnvelope sends the stored order unchanged, so every resend is byte-identical.

  • ef2f64e: The shared order.create shape check bounds string lengths and refuses NUL, and so does the v3 fiscal-figures check for its display and tax-code strings. The till cuts long names when it stores the order, refuses over-long pass-through references at finalize (and a payment reference as it's entered), refuses a searched or parked customer whose email or id the server would refuse, and validates the customer email at entry. Tills should ship this clamp before plugins adopt the new bounds, so no till sends a sale the server would now refuse.

  • 457162d: One reconcile feed per store session (#307, a release gate). The WooCommerce and Medusa reconcile feeds were module-level singletons, so after a store switch in one runtime, store A's queued tombstones and refetches could reach store B's database.

    • New factories. createWooCommerceConnector(), createMedusaConnector() and createMedusaAdminUserConnector() each build their own feed; createVendureConnector(options) already did. Build a connector per store session, anew on each sign-in or store change.
    • Deprecated exports. woocommerceConnector, medusaConnector, medusaAdminUserConnector and vendureConnector are deprecated: one instance for the whole app can leak queued reconcile work across stores. They are removed in 4.0.
    • A development warning. startReplication warns once when the same adapter object replicates into two collections at once.
    • Refetch budget by requests. refetchBatchSize on the reconcile adapters makes a page that enqueues n refetches take ceil(n / refetchBatchSize) request-budget slots (WooCommerce 100, Medusa 100, Vendure 1,000).
    • WooCommerce 426 errors. A foreign (non-WCPOS) 426 keeps the store's code beside its message, and the message is capped at 200 characters.
  • 222543b: Add RegisterCommandType, RegisterCommandEnvelope and AnyCommandEnvelope, register payloads and results, and the register server capability. CommandType and CommandEnvelope are unchanged.

    Record the local register_commands ledger through reconcileRegisterCommands, gated in useRegisterSession by its new commands and capabilities options. Commands are recorded but not sent. Medusa reads the register contract.

  • 8141c1c: Guard register commands, movement reasons and register ids, exporting RegisterMovementReasonError and RegisterIdInvalidError. Harden register outbox result handling and batch limits.

    Make CommandBatchRequest generic while preserving its existing default envelope type.

  • ce4f796: Replication pull errors are handled according to who can fix them, instead of every error being retried every 5 s forever. A till repeating a rejected token is the traffic a store's security plugin blocks.

    • @tallyui/core:
      • An error class declares fixedBy: 'till' | 'store' with a string code; errorKind(error) returns 'till', 'store' or 'transient'.
      • SyncNotice ({ code, since, fixedBy, software?, minVersion?, fix? }) describes a stopped pull.
      • ConnectorUnauthorizedError is fixed by the till.
    • @tallyui/database startReplication handles the three kinds and returns RxDB's state plus notice$ and resume():
      • till: one request, one notice, then the pull stays stopped until the app calls resume(), after sign-in. The pull stays stopped even when RxDB restarts the loop on page visibility.
      • store: one notice, then one attempt every 5 minutes (or the error's retryAfterMs, up to 1 hour). The notice clears itself on the first success, so a till recovers within 5 minutes of the owner's fix.
      • transient: a doubling delay from retryTime to 5 minutes. It waits at least a valid retryAfterMs (a finite number of zero or more), capped at 1 hour.
    • @tallyui/components: SyncStatus takes an optional pullNotice and tells the cashier in plain words that they can keep selling and who needs to act. It never shows a code, a backend name or a version the notice doesn't carry.
    • @tallyui/connector-woocommerce:
      • WooDateFilterError is fixed by the store, and carries software and minVersion.
      • WooMissingUuidError gains code: 'missing_plugin' and is fixed by the store.
    • @tallyui/connector-vendure: a new VendureTimezoneConfigError (store_misconfigured, with a plain fix) replaces the plain error when the updatedAt probe shows a server that isn't in UTC.
  • 5ed6281: The till computes tax with the store's rounding strategy (#287, ADR-071). ServerCapabilities gains taxRounding (core exports TaxRounding): per_order, per_line_items or per_rate_group_items, each with half_away_from_zero or half_up, or custom. Absent, and custom, mean today's per_order with half away from zero. TaxProvider takes rounding and rateCodes (tax class → the backend's rate name, for per_rate_group_items); the order records the strategy as taxRounding, and taxLinesByRate takes it as a fourth argument. The algorithms are in docs/contract/field-kinds.md.

  • 5a204a9: StoreSettings gains a derived taxRounding. useStoreSettings fills it from the context's capabilities, or else from one read of the connector's capabilities() made before the settings are ready, so each sign-in emits the settings once with the rounding known and no later change holds a sale; a failed read or a connector without capabilities gives the default rounding. taxProviderProps passes it to TaxProvider as rounding, so the till rounds tax like the store with no app code (#324). custom passes no rounding, and an explicit rounding prop still wins.

  • 7d1bc98: Add parseTaxRounding and parseInfoCapabilities, which read /tally/v1/info including its top-level taxRounding (#287). The Medusa connector's capability read now carries the store's taxRounding.

  • 8cf3ea4: A till tells "sign in again" apart from "signed in, but not allowed" (found by the Medusa POS app's adoption).

    • ConnectorUnauthorizedError.status is now required, typed 401 | 403, and set by meaning at every connector.
      • 401: the credentials are not accepted, so sign in again. code: 'unauthorized', fixed by the till.
      • 403: the till is signed in but not allowed. code: 'forbidden', fixed by the store.
      • Vendure answers a signed-out session with 403 too. Its connector checks who is signed in first, so a confirmed sign-out is always 401.
    • A 403 on the pull gives the forbidden notice, never a sign-out. The pull retries on the store schedule and clears by itself once the store owner grants the permission. SyncStatus shows "Products aren't updating: your account isn't allowed to do this on this store." with "You can keep selling. Ask the store owner."
    • The customer picker shows "Your account isn't allowed to do this on this store. Ask the store owner." for a 403, instead of asking the cashier to sign in again.
  • e15f389: The Vendure connector supplies its tax rate names, so a per_rate_group_items store groups a sale's tax the way Vendure does (#324). Apps no longer fetch the names themselves.

    • StoreSettings.taxRateCodes (core, optional): the backend's tax rate name per tax class, keyed like taxRatesPpm, including default.
    • vendureStoreSettings reads each rate's name in the tax-rate query it already runs, so no extra request is made. Only the rates taxRatesPpm uses count, and default follows the same default-category rule. taxRateCodes is left out when no names come back.
    • taxProviderProps(settings) passes taxRateCodes to <TaxProvider> as rateCodes.
  • ddd9e85: The WooCommerce catalogue reconcile uses the WCPOS products fast path (#313). When wcpos/v2/status lists products_id_fast_path in capabilities, the whole catalogue is listed in one request (per_page=-1, _fields=id,date_modified_gmt,stock_quantity,stock_status) instead of pages of 100. A store that refuses it, or answers with something that is not a list, is listed page by page in the same pass.

    • Keyed on the remote id: both WooCommerce listings key on the numeric product id, never the till-local uuid.
    • CatalogueReconcileAdapter.matchKey (core, optional): an adapter whose listing carries no primary key declares how to match a local document. The catalogue runner indexes the local documents by it for each pass. Deletion is unchanged: confirmGone, then the mass-delete brake, by primary key.
    • remote on the keyed reconcile feed (core, optional): a listed product the till does not hold yet is matched back by its remote id, so it is delivered rather than dropped.

Patch Changes

  • 5c90aed: parseCommandResult now accepts a total_mismatch's bridgeMinor and the tax_rate_mismatch warning code, so a plugin replaying a stored v3 result no longer fails. knownWarnings is lenient about a bad optional bridgeMinor (dropping just that field, not the whole warning) and about a non-array warnings value. OrdersList's rounding line now reads "Store calculated …; a rounding line of … brought it to …". Catalogue keeps its input array's identity when the stock overlay changes nothing, and takes the latest of lastStockCheckAt, the provider's stockOverlayAsOf and lastSyncedAt for its "stock as of" time.

  • 6673faf: RxDB 17.5.0.

    • @tallyui/storage-sqlite:
      • Its rxdb-premium peer is now 17.5.0. Apps install rxdb-premium@17.5.0 together with rxdb@17.5.0.
      • Its storages set RxDB 17's premium flag at import and when called, so the 13-collection cap never applies.
    • @tallyui/pos:
      • Its rxdb peer is now ~17.5.0.
      • Opening pos_orders rejects with PosOrderOpenClosedError when the database closes during a migration: RxDB 17.5.0 cancels the migration on close. The open first waits for any write already in flight, so none reaches a closed store.
      • An open that needs no migration resolves only once RxDB allows writes, so a sale saved straight after it is never refused with COL25.
    • @tallyui/database:
      • createTallyDatabase returns an RxDB 17 database.
      • In development it adds RxDB's dev-mode plugin when a database is created, not at import.
    • Stored data: a till's SQLite data written by RxDB 16.21.1 opens unchanged under 17.5.0, and migrates its schema versions.

    Upgrade notes

    • Storage is one-way. Once a till has opened this version, pos_orders is at schema version 4, and an older build (such as @tallyui/pos 2.0.0 on RxDB 16.21.1) opens it without an error but shows no orders, so it sends none of the pending ones until the till is upgraded again. Nothing is deleted: the next upgrade recovers every order, including a sale rung during the rollback. Never roll an app back across this version, and never re-ring sales it hides: a re-rung sale is a second sale, and the upgrade sends both. See ADR-069 in docs/DECISIONS.md.
    • Web apps ship the 17.5.0 storage worker with the 17.5.0 main thread. A cached 16.x worker with a 17.5.0 main thread is untested and unsupported.
    • Apps pin rxdb and rxdb-premium to exactly 17.5.0.
    • RxDB 17 defaults a replication's toggleOnDocumentVisible to true (16.21.1: false). It then resyncs when the tab becomes visible, and no longer simulates activity to keep a hidden tab awake, so a browser may throttle a hidden tab's pull. RxDB pauses a hidden tab's replication only when that tab isn't the leader; a single-instance database is always the leader (read in 17.5.0's plugins/replication source, not tested).
  • c48e1dd: Store settings follow-ups to #339 and #341 (#340):

    • A signed-out till is asked to sign in, not shown "Retrying…". When the capabilities read fails with an error only the till can fix, useStoreSettings gives error without nextRetryAt and doesn't retry by itself, so the app prompts. That covers a till-class error (a 401, or a till that needs updating) and a SignInError with code: 'invalid_credentials'; any other sign-in error still waits and retries. Every other failure still waits and retries.
    • A /tally/v1/info JSON body that isn't an object (null, an array or a scalar) is unknown, not the default rounding.
  • 1f4d0ab: isStorageWorkerStartError and isStorageWorkerFailure also recognise RxDB's RM1, a stale storage worker built on another RxDB version (for example a cached old worker after an upgrade), so apps show their reload advice for it. Both call the new isRxdbRemoteVersionMismatch in @tallyui/core, which recognises RM1 by structure only: an RxError's own code, or the remote storage's could not create instance wrapping of an RxError's JSON. @tallyui/storage-sqlite now has @tallyui/core as a peer dependency.

  • 3cf5452: Follow-ups to the 401/403 split (#345):

    • Vendure: a signed-in user missing a permission (confirmed by the session probe) is now ConnectorUnauthorizedError with status: 403, the forbidden notice, instead of a plain transient error.
    • WooCommerce: a 403 from the JWT-auth plugin (jwt_auth_*) reaches the till only with a valid token on WCPOS 1.10.0–1.10.7 (wcpos/woocommerce-pos#1863). It is now WooPluginUpdateRequiredError (unsupported_store, WCPOS 1.10.8): the store owner updates WCPOS, and the till is never sent into a sign-in loop.
    • ConnectorUnauthorizedError: only a 403 is forbidden. A caller that omits status gets unauthorized, as before 3.0.
    • Docs: the customer picker's onError, the replication guide's error classes, and the connector comments now say that only a 401 means sign in again.

@tallyui/database

Major Changes

  • 6673faf: RxDB 17.5.0.

    • @tallyui/storage-sqlite:
      • Its rxdb-premium peer is now 17.5.0. Apps install rxdb-premium@17.5.0 together with rxdb@17.5.0.
      • Its storages set RxDB 17's premium flag at import and when called, so the 13-collection cap never applies.
    • @tallyui/pos:
      • Its rxdb peer is now ~17.5.0.
      • Opening pos_orders rejects with PosOrderOpenClosedError when the database closes during a migration: RxDB 17.5.0 cancels the migration on close. The open first waits for any write already in flight, so none reaches a closed store.
      • An open that needs no migration resolves only once RxDB allows writes, so a sale saved straight after it is never refused with COL25.
    • @tallyui/database:
      • createTallyDatabase returns an RxDB 17 database.
      • In development it adds RxDB's dev-mode plugin when a database is created, not at import.
    • Stored data: a till's SQLite data written by RxDB 16.21.1 opens unchanged under 17.5.0, and migrates its schema versions.

    Upgrade notes

    • Storage is one-way. Once a till has opened this version, pos_orders is at schema version 4, and an older build (such as @tallyui/pos 2.0.0 on RxDB 16.21.1) opens it without an error but shows no orders, so it sends none of the pending ones until the till is upgraded again. Nothing is deleted: the next upgrade recovers every order, including a sale rung during the rollback. Never roll an app back across this version, and never re-ring sales it hides: a re-rung sale is a second sale, and the upgrade sends both. See ADR-069 in docs/DECISIONS.md.
    • Web apps ship the 17.5.0 storage worker with the 17.5.0 main thread. A cached 16.x worker with a 17.5.0 main thread is untested and unsupported.
    • Apps pin rxdb and rxdb-premium to exactly 17.5.0.
    • RxDB 17 defaults a replication's toggleOnDocumentVisible to true (16.21.1: false). It then resyncs when the tab becomes visible, and no longer simulates activity to keep a hidden tab awake, so a browser may throttle a hidden tab's pull. RxDB pauses a hidden tab's replication only when that tab isn't the leader; a single-instance database is always the leader (read in 17.5.0's plugins/replication source, not tested).

Minor Changes

  • 894b6ae: One catalogue reconcile runner replaces the id and fingerprint reconcile runners (#248, part A). startIdReconcile and startFingerprintReconcile remain as thin wrappers with their options and results.

    • startCatalogueReconcile (new) compares the backend's product listing with the till in one pass and hands what differs to the collection's pull. It:
      • stays within a request budget (30 a minute by default) instead of a page cap, so large catalogues never truncate;
      • keeps its daily gate and a resume cursor in RxDB local documents, so it does not run on every start, and it resumes after an interruption;
      • deletes only in an uninterrupted pass, and only what the connector confirms gone. The mass-delete brake is checked on the candidates before the connector is asked, and the connector is asked in chunks (confirmChunk, default 100), each within the budget;
      • stops or skips by errorKind;
      • logs what it did through an optional log callback.
    • Behaviour changes for the existing runners:
      • there is no pass 5 s after every start: the daily gate is checked at the start delay and then hourly;
      • maxPages is ignored;
      • requests are paced by the budget;
      • differences are refetched page by page;
      • apps that run more than one runner on the same collection pass a distinct stateId.
    • createReconcileFeed:
      • it takes an optional key to match fetched documents by the local primary key, so it works where doc.id is not the primary key; fetchByIds then receives the queued entries;
      • a tombstone entry is deleted without a fetch;
      • a fetched document keeps a _deleted the connector set.
    • @tallyui/core adds CatalogueReconcileAdapter and TallyConnector.reconcile.catalogue.
    • Connector collections enable RxDB local documents.
  • 2ecaa36: A replication adapter can set pull.batchSize, and the Medusa connector pulls 500 products per page.

  • e77d552: A reconcile result now says whether its pass was complete, so a till never shows a partial pass's "0 not sold" as current (found by the Medusa POS app's 3.0.0-next.0 adoption).

    • complete on CatalogueReconcileSummary, FingerprintReconcileResult and IdReconcileResult: true when the pass ran from its first page to its last in one go, so unlisted and unreported are real counts. It is false for a resumed pass, whose counts are 0. For the fingerprint result, the pass must also have read at least one page.
    • lastCompleteAt on the runner's and the fingerprint wrapper's state: when the last complete pass finished. It is persisted with the runner's gate, and is available before the first pass after a restart.
    • A failed pass that finished no page is restarted rather than resumed. It re-reads from the first page anyway, so it now runs as a complete pass.
  • 457162d: One reconcile feed per store session (#307, a release gate). The WooCommerce and Medusa reconcile feeds were module-level singletons, so after a store switch in one runtime, store A's queued tombstones and refetches could reach store B's database.

    • New factories. createWooCommerceConnector(), createMedusaConnector() and createMedusaAdminUserConnector() each build their own feed; createVendureConnector(options) already did. Build a connector per store session, anew on each sign-in or store change.
    • Deprecated exports. woocommerceConnector, medusaConnector, medusaAdminUserConnector and vendureConnector are deprecated: one instance for the whole app can leak queued reconcile work across stores. They are removed in 4.0.
    • A development warning. startReplication warns once when the same adapter object replicates into two collections at once.
    • Refetch budget by requests. refetchBatchSize on the reconcile adapters makes a page that enqueues n refetches take ceil(n / refetchBatchSize) request-budget slots (WooCommerce 100, Medusa 100, Vendure 1,000).
    • WooCommerce 426 errors. A foreign (non-WCPOS) 426 keeps the store's code beside its message, and the message is capped at 200 characters.
  • ce4f796: Replication pull errors are handled according to who can fix them, instead of every error being retried every 5 s forever. A till repeating a rejected token is the traffic a store's security plugin blocks.

    • @tallyui/core:
      • An error class declares fixedBy: 'till' | 'store' with a string code; errorKind(error) returns 'till', 'store' or 'transient'.
      • SyncNotice ({ code, since, fixedBy, software?, minVersion?, fix? }) describes a stopped pull.
      • ConnectorUnauthorizedError is fixed by the till.
    • @tallyui/database startReplication handles the three kinds and returns RxDB's state plus notice$ and resume():
      • till: one request, one notice, then the pull stays stopped until the app calls resume(), after sign-in. The pull stays stopped even when RxDB restarts the loop on page visibility.
      • store: one notice, then one attempt every 5 minutes (or the error's retryAfterMs, up to 1 hour). The notice clears itself on the first success, so a till recovers within 5 minutes of the owner's fix.
      • transient: a doubling delay from retryTime to 5 minutes. It waits at least a valid retryAfterMs (a finite number of zero or more), capped at 1 hour.
    • @tallyui/components: SyncStatus takes an optional pullNotice and tells the cashier in plain words that they can keep selling and who needs to act. It never shows a code, a backend name or a version the notice doesn't carry.
    • @tallyui/connector-woocommerce:
      • WooDateFilterError is fixed by the store, and carries software and minVersion.
      • WooMissingUuidError gains code: 'missing_plugin' and is fixed by the store.
    • @tallyui/connector-vendure: a new VendureTimezoneConfigError (store_misconfigured, with a plain fix) replaces the plain error when the updatedAt probe shows a server that isn't in UTC.
  • ddd9e85: The WooCommerce catalogue reconcile uses the WCPOS products fast path (#313). When wcpos/v2/status lists products_id_fast_path in capabilities, the whole catalogue is listed in one request (per_page=-1, _fields=id,date_modified_gmt,stock_quantity,stock_status) instead of pages of 100. A store that refuses it, or answers with something that is not a list, is listed page by page in the same pass.

    • Keyed on the remote id: both WooCommerce listings key on the numeric product id, never the till-local uuid.
    • CatalogueReconcileAdapter.matchKey (core, optional): an adapter whose listing carries no primary key declares how to match a local document. The catalogue runner indexes the local documents by it for each pass. Deletion is unchanged: confirmGone, then the mass-delete brake, by primary key.
    • remote on the keyed reconcile feed (core, optional): a listed product the till does not hold yet is matched back by its remote id, so it is delivered rather than dropped.

Patch Changes

  • 539d2ff: The stock, id and fingerprint reconciles read and write in bounded chunks, so app queries don't wait behind a whole pass.

  • d225c58: Internal @tallyui/* peer dependencies are published as a caret range (for example ^2.1.0) instead of an exact version. The packages still release together at one version.

  • 6f83dc5: The catalogue reconcile's mass-delete brake now explains itself in plain words. Its kept event with reason: 'brake' carries a message a till can show to the store owner, for example: "12 products the online store no longer lists were kept on this till: removing that many at once needs a check. If they were hidden or removed on purpose, the person who manages this till can allow the removal." The console warning uses the same words, plus a hint for developers (allowMassDelete: true).

    The WooCommerce tests now model WCPOS's "POS only products" setting, and pin that a product hidden from the POS after sync is removed from the till by the next reconcile pass, while a bulk hide is held by the brake.

  • 1223353: A stale duplicate copy of a store product no longer stays on the till for good (#369). When two local products share one store id, the catalogue check now makes the copy its index did not pick a deletion candidate, and logs a duplicate event with the code duplicate_match_key. The copy is tombstoned only when confirmGone proves the store doesn't back it, and the mass-delete brake still applies. WooCommerce's confirmGone now also confirms a local whose id is live but whose uuid isn't the store's for that id. The store listing is the source of truth, and a later pull restores anything the store still backs.

  • a8b58a4: The catalogue and fingerprint reconciles keep lastCompleteAt honest (#338).

    • The fingerprint reconcile's state$ moves lastCompleteAt only on a pass whose result is complete. A pass that read no pages, which gives complete: false, no longer stamps it.
    • A new runner still shows the persisted value before its first pass.
    • The catalogue runner's start-up load now updates lastCompleteAt only when the stored value is newer, so a load that resolves after a pass has completed can't roll it back.
  • 20b6321: A catalogue pass that yields no pages at all no longer counts (#368). It is not complete, it moves neither lastCompleteAt nor the schedule's last completed time, and the gate runs it again at the next check. A page with no entries still counts: that is an adapter reporting an empty catalogue. Before this, a restarted fingerprint reconcile could show a zero-page pass's time as its last complete one.

  • 1f4d0ab: isStorageWorkerStartError and isStorageWorkerFailure also recognise RxDB's RM1, a stale storage worker built on another RxDB version (for example a cached old worker after an upgrade), so apps show their reload advice for it. Both call the new isRxdbRemoteVersionMismatch in @tallyui/core, which recognises RM1 by structure only: an RxError's own code, or the remote storage's could not create instance wrapping of an RxError's JSON. @tallyui/storage-sqlite now has @tallyui/core as a peer dependency.

  • 8cf3ea4: A till tells "sign in again" apart from "signed in, but not allowed" (found by the Medusa POS app's adoption).

    • ConnectorUnauthorizedError.status is now required, typed 401 | 403, and set by meaning at every connector.
      • 401: the credentials are not accepted, so sign in again. code: 'unauthorized', fixed by the till.
      • 403: the till is signed in but not allowed. code: 'forbidden', fixed by the store.
      • Vendure answers a signed-out session with 403 too. Its connector checks who is signed in first, so a confirmed sign-out is always 401.
    • A 403 on the pull gives the forbidden notice, never a sign-out. The pull retries on the store schedule and clears by itself once the store owner grants the permission. SyncStatus shows "Products aren't updating: your account isn't allowed to do this on this store." with "You can keep selling. Ask the store owner."
    • The customer picker shows "Your account isn't allowed to do this on this store. Ask the store owner." for a 403, instead of asking the cashier to sign in again.
  • 136343c: The WooCommerce connector gets a daily reconciliation pass (#248, part B), a safety net for edits the incremental pull can miss: the spring-forward hour, an over-excluding filter, a same-second edit, a shift without X-WP-Total, trashed or unpublished products, and stock written without a modified-time bump.

    • reconcile.catalogue lists the published catalogue with no date filter, comparing date, stock quantity and stock status. It re-reads deletion candidates by id and removes only those that are gone, trashed or unpublished. Everything else it re-pulls through the collection's own pull.
    • replication.products now combines the product pull with the reconcile feed, with legacyKey: 'products', so existing installs keep their checkpoint.
    • A product the store cannot be asked about (no numeric id) is never deleted.
    • The WCPOS bulk-ID fast path is read from wcpos/v2/status capabilities (products_id_fast_path). It stays dormant until wcpos/woocommerce-pos#2113 ships.
    • @tallyui/database: the catalogue runner's gate check has a 60-second floor, so a bad interval can no longer re-arm it on every tick.

@tallyui/components

Minor Changes

  • 04905ef: Catalogue now applies the provider's reconciled stock overlay itself (tiles, search and the variant chooser agree), and useStockOverlaid and useStockOverlayAsOf are new.

  • 78d324e: Add useSale.setCustomer and ReceiptData.header.customer, customerTraits, CustomerPicker, generic CustomerSelect/CustomerCard with traits overrides, CustomerForm.showAddress, and the receipt's customer line.

    CustomerSelect rows are now pressable, so choosing a result works on the web (it previously did nothing).

  • 130d28e: A store that keeps answering 404 is no longer silent. After 3 consecutive 404 answers the order and register outboxes set OutboxState.backendMissing: { since } (when the first of them arrived), and SyncStatus tells the cashier in plain words that sales aren't reaching the online store and are saved on the till, with a detail line for the store owner, instead of showing retrying (status_404); the stuck line shows the same words, with no raw code. SyncStatus takes an optional pluginName (default 'the POS plugin') for that detail. The outboxes keep retrying on their normal backoff, and orders stay pending, so a 404 during a deploy blip recovers on its own. The next answer that isn't a 404 clears it; an offline (network) retry changes nothing. Pass one createBackendNotFound() tracker as backendNotFound to both createOrderOutbox and createRegisterOutbox so their 404s count together and the notice shows once, whichever outbox meets it first; without it, each outbox keeps its own.

  • 9e1032f: One order the store keeps failing no longer stops every later sale. After 5 server-answered failures (offline never counts) the order outbox probes the pending queue one order per backoff interval, oldest first; once the store takes one, the orders whose probes failed are isolated and retried alone, and batching resumes. If no probe gets through, the store is down: nothing is isolated. Retries alternate between the batch (or the probe) and one due isolated order, one request per interval, so neither can starve the other, and isolated orders take turns. An order the store has kept failing for 15 minutes of answered time, on its own clock, is flagged as stuck and stays pending: OutboxState.stuck, with a per-order entry in stuck.orders, useOrderOutbox's stuckCommandIds, needsAttention's stuckCommandIds option, OrdersList's stuck prop (each order with its own time and reason), and SyncStatus's "Not syncing" line. An offline failure pauses every clock, and the store's next answer of any kind resumes them all; a flagged order stays flagged through an offline spell, since a paused clock keeps its answered time. The HTTP transport now reports a request that got no answer in time as timeout, which counts like a 503 and never pauses a clock, and keeps network for a store it could not reach.

  • a9cdfc0: Migrate pos_orders to version 4 with optional localWarnings and serverFailures. Record omitted customer details and dropped payment references on the stored order, and show these warnings in the orders list; serverFailures is declared for the next outbox update.

  • c92e96e: ProductGrid renders a virtualized FlatList, with its props unchanged.

  • ce4f796: Replication pull errors are handled according to who can fix them, instead of every error being retried every 5 s forever. A till repeating a rejected token is the traffic a store's security plugin blocks.

    • @tallyui/core:
      • An error class declares fixedBy: 'till' | 'store' with a string code; errorKind(error) returns 'till', 'store' or 'transient'.
      • SyncNotice ({ code, since, fixedBy, software?, minVersion?, fix? }) describes a stopped pull.
      • ConnectorUnauthorizedError is fixed by the till.
    • @tallyui/database startReplication handles the three kinds and returns RxDB's state plus notice$ and resume():
      • till: one request, one notice, then the pull stays stopped until the app calls resume(), after sign-in. The pull stays stopped even when RxDB restarts the loop on page visibility.
      • store: one notice, then one attempt every 5 minutes (or the error's retryAfterMs, up to 1 hour). The notice clears itself on the first success, so a till recovers within 5 minutes of the owner's fix.
      • transient: a doubling delay from retryTime to 5 minutes. It waits at least a valid retryAfterMs (a finite number of zero or more), capped at 1 hour.
    • @tallyui/components: SyncStatus takes an optional pullNotice and tells the cashier in plain words that they can keep selling and who needs to act. It never shows a code, a backend name or a version the notice doesn't carry.
    • @tallyui/connector-woocommerce:
      • WooDateFilterError is fixed by the store, and carries software and minVersion.
      • WooMissingUuidError gains code: 'missing_plugin' and is fixed by the store.
    • @tallyui/connector-vendure: a new VendureTimezoneConfigError (store_misconfigured, with a plain fix) replaces the plain error when the updatedAt probe shows a server that isn't in UTC.
  • a94b255: OutboxState gains rejected?: number: the order outbox publishes the count of pos_orders the store refused (syncStatus: 'rejected') wherever it publishes pending, so it rises when a batch result rejects an order and falls when requeue() sends one again. The register outbox leaves it unset. While rejected is above 0, SyncStatus never says "Sales are up to date.": its whole status line (label, polite live region and iOS announcement) is "1 sale needs attention · The online store refused it. Ask the store owner to look at the till's sync log." or "{n} sales need attention · The online store refused them. Ask the store owner to look at the till's sync log.", in place of the stuck and backend-missing sentences and the sending or retrying line. What else waits stays in the count: "1 sale needs attention, 5 waiting to sync · …" with other sales pending, "…, 2 till updates waiting to sync · …" with till updates, and "…, 5 sales and 2 till updates waiting to sync · …" with both. Below it, "Refused sales stay on this till under Needs attention, each with what to do next.", then the backend-missing detail when the store is missing. With rejected 0 or unset, nothing changes. When the store refused a whole batch (refused set, no sale carrying a code), the status line is the waiting count followed by " · The online store refused the last send. This till will try again with the next sale, or when the app is reopened." in place of the stuck or backend-missing sentence, and the sending or retrying line (which read "Retrying in 0 s.") is hidden; refused sales still outrank it. With only till updates waiting and the register outbox refused, it ends "…try again with the next till update." instead; with a sale waiting, the sales line wins.

  • df80ead: SyncStatus takes an optional registerState (the register outbox's state): waiting till updates are counted ("1 till update waiting to sync", or named beside the sales), so it never says the sales are up to date while any wait, and with no sale waiting the backend-missing sentence says till updates aren't reaching the online store, or, once registerState.stuck is set, "Till updates haven't reached the online store since {time}. …". The backend-missing detail now reads "This till couldn't find {pluginName} on the online store. …". With nothing waiting, the line is only "Sales are up to date." (it replaces "All sales synced"), with no sending, retrying or problem text after it; if the store is missing, the detail reads "This till couldn't find {pluginName} on the online store the last time it checked. …". The status line's accessibility label is the whole visible line instead of "Sync status". The order and register outboxes let go of a shared backendNotFound tracker on stop() and take it up again on start() or flush().

  • 8cf3ea4: A till tells "sign in again" apart from "signed in, but not allowed" (found by the Medusa POS app's adoption).

    • ConnectorUnauthorizedError.status is now required, typed 401 | 403, and set by meaning at every connector.
      • 401: the credentials are not accepted, so sign in again. code: 'unauthorized', fixed by the till.
      • 403: the till is signed in but not allowed. code: 'forbidden', fixed by the store.
      • Vendure answers a signed-out session with 403 too. Its connector checks who is signed in first, so a confirmed sign-out is always 401.
    • A 403 on the pull gives the forbidden notice, never a sign-out. The pull retries on the store schedule and clears by itself once the store owner grants the permission. SyncStatus shows "Products aren't updating: your account isn't allowed to do this on this store." with "You can keep selling. Ask the store owner."
    • The customer picker shows "Your account isn't allowed to do this on this store. Ask the store owner." for a 403, instead of asking the cashier to sign in again.

Patch Changes

  • ba63f04: CommandWarning gains { code: 'customer_ignored'; customerId: string } (#266): a sale whose customerId doesn't resolve is kept as a guest sale. knownWarnings keeps it and parseCommandResult accepts it when customerId is 1 to 64 characters; the orders list renders it.

  • 24b74fd: CommandWarning gains { code: 'figures_mismatch'; fields: Array<{ field: 'subtotalMinor' | 'taxMinor' | 'discountMinor' | (string & {}); tillMinor: number; serverMinor: number }> } (#257): one warning per sale listing each of the till's figures that differs from the server's own computation. parseCommandResult accepts it when fields is non-empty, each field is one of the three names with none repeated, and each entry's two values are different safe integers. knownWarnings applies the same rules but keeps a field name it doesn't know (any non-empty string), since a newer store may send one; the orders list renders it, an unknown field by its raw name.

  • d6a5073: The outbox freezes an order an older till stored before it first sends it (freezeSentForm, which finalizeOrder uses too):

    • line names, discount labels and payment references are cut to 255 characters, but ids never are;
    • a customer email or id that order.create would refuse is left out;
    • the frozen form is written back, so the receipt and the store see the same bytes.

    So an order stored before the upgrade and still unsent is never refused as invalid_payload.

    New type: SentOrder, an Order whose customer id may be missing. The receipt stage, buildReceiptData and Receipt take it, and a plain Order still fits.

  • 27d736e: CommandWarning gains bridgeMinor on total_mismatch and a new tax_rate_mismatch code, and the till now ignores warning codes it doesn't know (knownWarnings), instead of showing them as a store total.

  • fd882bc: The stuck line says "no answer from the store" for a timeout.

  • eb203b4: Review follow-ups with no behaviour change (#356, #358):

    • SyncStatus and OrdersList build their "since {time}" and "since about {time}" text with one shared helper.
    • useRegisterOutbox's docs now say when transport() is called, and that the latest isEnabled and onResult are used without restarting the outbox.
  • af623c9: The order.create string lengths move from payloadShapeErrors to the new payloadBoundErrors, which precheckCommand calls after the replay lookup, so an applied order resent with a long title replays as duplicate; payloadShapeErrors keeps the types, the customerId and sessionId bounds and the NUL check. useSale applies a tender before logging a dropped reference, and add() refuses a product whose id or v3 tax code finalize would refuse; the tender's reference field caps at 255 characters. finalizeOrder now freezes the sent form (names and discount labels cut, an unsendable customer email or id left out) and toOrderCreateEnvelope sends the stored order unchanged, so every resend is byte-identical.

  • ef2f64e: The shared order.create shape check bounds string lengths and refuses NUL, and so does the v3 fiscal-figures check for its display and tax-code strings. The till cuts long names when it stores the order, refuses over-long pass-through references at finalize (and a payment reference as it's entered), refuses a searched or parked customer whose email or id the server would refuse, and validates the customer email at entry. Tills should ship this clamp before plugins adopt the new bounds, so no till sends a sale the server would now refuse.

  • d225c58: Internal @tallyui/* peer dependencies are published as a caret range (for example ^2.1.0) instead of an exact version. The packages still release together at one version.

  • 6bbd1ba: Each sale records the tax rounding its figures were computed with (#287): finalizeOrder writes taxRounding on the stored order, the default (per_order, half_away_from_zero) included, and custom as { granularity: 'custom' }. It is the till's own record and is never sent in order.create. The Z report splits each sale's tax by rate with the strategy that sale recorded, so its rows are the receipts' rows, and its breakdowns.tax_rounding_mixed is true when a session's sales used more than one strategy (a custom sale counts as the default it applied); ClosureSheet then says so, and buildClosureDocument carries the same line ready to print as closure.tax_rounding_note (TAX_ROUNDING_MIXED_NOTE), for the apps' closure templates. PosOrder.taxRounding is now required in the type.

    pos_orders moves to schema version 6: taxRounding is required, and the migration records the default on every older sale, the only rounding any earlier build used. Like version 5, this storage is one-way: an older build opens it but shows no orders, so never roll an app back across it (ADR-069). Before 3.0.0 ships, #242's OPFS upgrade proof is rerun against version 6.

  • 5c90aed: parseCommandResult now accepts a total_mismatch's bridgeMinor and the tax_rate_mismatch warning code, so a plugin replaying a stored v3 result no longer fails. knownWarnings is lenient about a bad optional bridgeMinor (dropping just that field, not the whole warning) and about a non-array warnings value. OrdersList's rounding line now reads "Store calculated …; a rounding line of … brought it to …". Catalogue keeps its input array's identity when the stock overlay changes nothing, and takes the latest of lastStockCheckAt, the provider's stockOverlayAsOf and lastSyncedAt for its "stock as of" time.

  • c00e1ea: OrdersList shows a rejected sale's refusal in the cashier's words, one sentence per error code (#269), in both Needs attention and Recent, and never the store's own message. An unknown code, or a rejected sale with no error, shows platform_error's sentence: "The online store refused this sale. Ask the store owner to look at the till's sync log." An idempotency_mismatch shows its sentence ("… Don't send it again; ask the store owner to compare the two.") in place of the old "This sale needs checking against the store before it can be sent again." line, and still has no Retry. The order outbox logs every refusal once to the sync log as "Order refused by the store" with the order id, the code and the store's message: a warning, or an error for unsupported_version (whose log previously used the message itself as its text).

  • 6673faf: RxDB 17.5.0.

    • @tallyui/storage-sqlite:
      • Its rxdb-premium peer is now 17.5.0. Apps install rxdb-premium@17.5.0 together with rxdb@17.5.0.
      • Its storages set RxDB 17's premium flag at import and when called, so the 13-collection cap never applies.
    • @tallyui/pos:
      • Its rxdb peer is now ~17.5.0.
      • Opening pos_orders rejects with PosOrderOpenClosedError when the database closes during a migration: RxDB 17.5.0 cancels the migration on close. The open first waits for any write already in flight, so none reaches a closed store.
      • An open that needs no migration resolves only once RxDB allows writes, so a sale saved straight after it is never refused with COL25.
    • @tallyui/database:
      • createTallyDatabase returns an RxDB 17 database.
      • In development it adds RxDB's dev-mode plugin when a database is created, not at import.
    • Stored data: a till's SQLite data written by RxDB 16.21.1 opens unchanged under 17.5.0, and migrates its schema versions.

    Upgrade notes

    • Storage is one-way. Once a till has opened this version, pos_orders is at schema version 4, and an older build (such as @tallyui/pos 2.0.0 on RxDB 16.21.1) opens it without an error but shows no orders, so it sends none of the pending ones until the till is upgraded again. Nothing is deleted: the next upgrade recovers every order, including a sale rung during the rollback. Never roll an app back across this version, and never re-ring sales it hides: a re-rung sale is a second sale, and the upgrade sends both. See ADR-069 in docs/DECISIONS.md.
    • Web apps ship the 17.5.0 storage worker with the 17.5.0 main thread. A cached 16.x worker with a 17.5.0 main thread is untested and unsupported.
    • Apps pin rxdb and rxdb-premium to exactly 17.5.0.
    • RxDB 17 defaults a replication's toggleOnDocumentVisible to true (16.21.1: false). It then resyncs when the tab becomes visible, and no longer simulates activity to keep a hidden tab awake, so a browser may throttle a hidden tab's pull. RxDB pauses a hidden tab's replication only when that tab isn't the leader; a single-instance database is always the leader (read in 17.5.0's plugins/replication source, not tested).
  • 0e4c9cc: The "since" a cashier reads is a real time (#253). OutboxState.stuck (both outboxes) gains firstFailedAt: the wall-clock time the first failure of the current stuck run was answered, so an offline gap no longer moves it. since keeps its meaning, the clock's virtual start, and still drives the 15-minute threshold. firstFailedAt is kept in memory only. After a restart it is absent, and SyncStatus and OrdersList show the stored time instead, worded "since about 2:49 AM".

  • d6079b4: SyncStatus inserts the plugin name and times literally (a $&, $1 or $$ in them is kept as written), and with only till updates waiting shows the register outbox's sending and retrying text and countdown. It shows no raw reason code: a stuck order now shows the "Sales haven't reached the online store since {time}." sentence (for till updates alone, "Till updates haven't …"), store missing or not. Sending and retrying are a short line of their own below the status line: "Sending…" or "Retrying in {n} s.". The status line and each pull-notice line are polite live regions (aria-live="polite" on web, accessibilityLiveRegion on Android) and on iOS are announced when their text changes, both in one announcement when they change together. What is announced is only the substance (counts, the sentence, the notice): the sending or retrying line is outside any live region, so it is never announced. OrdersList shows no reason code either: a stuck order reads "Hasn't reached the online store since {time}." with the hour numeric, and a rejected order shows the store's message alone (nothing when it has none), never its error code.

  • e51f1b7: Times shown to a cashier no longer force a leading zero on the hour (#252): ProductStockBadge's "as of" time and the catalogue's time label now read "2:49 AM", not "02:49 AM", on a 12-hour clock, like SyncStatus and the orders list.

  • 3cf5452: Follow-ups to the 401/403 split (#345):

    • Vendure: a signed-in user missing a permission (confirmed by the session probe) is now ConnectorUnauthorizedError with status: 403, the forbidden notice, instead of a plain transient error.
    • WooCommerce: a 403 from the JWT-auth plugin (jwt_auth_*) reaches the till only with a valid token on WCPOS 1.10.0–1.10.7 (wcpos/woocommerce-pos#1863). It is now WooPluginUpdateRequiredError (unsupported_store, WCPOS 1.10.8): the store owner updates WCPOS, and the till is never sent into a sign-in loop.
    • ConnectorUnauthorizedError: only a 403 is forbidden. A caller that omits status gets unauthorized, as before 3.0.
    • Docs: the customer picker's onError, the replication guide's error classes, and the connector comments now say that only a 401 means sign in again.
  • 6278d8d: The register outbox can start when its store opens, as the order outbox does (#290). The new useRegisterOutbox({ commands, transport, deviceId, isEnabled?, onResult?, backendNotFound? }) runs createRegisterOutbox over an already-open register_commands collection, and calls start() so that till updates left pending (after a refused batch, for instance) go out when the app reopens. It returns { state, flush }. A new collection or device id restarts the outbox, and commands: null leaves it idle. Apps that create the register outbox themselves should switch to this hook. SyncStatus's till-updates refusal line now ends "…with the next till update, or when the app is reopened.", matching the sales line.

  • cbf26fd: The WooCommerce connector sends WCPOS's protocol signal, so a WCPOS 2.0 store does not refuse it (#296). Every request carries X-WCPOS-Protocol: 2 and X-WCPOS-Client: tallyui/<connector version>. WCPOS's 2.0 gate refuses POS-marked wcpos/v2 requests without protocol 2, and protocol 2 is a pure declaration the connector already conforms to. The headers are harmless on WCPOS 1.x.

    If a store still answers 426 (wcpos_update_required), the new WooTillUpdateRequiredError (till_update_required, fixed by the till) stops the product pull after one request. SyncStatus then tells the cashier: "Products aren't updating: this till needs updating."

@tallyui/storage-sqlite

Major Changes

  • 6673faf: RxDB 17.5.0.

    • @tallyui/storage-sqlite:
      • Its rxdb-premium peer is now 17.5.0. Apps install rxdb-premium@17.5.0 together with rxdb@17.5.0.
      • Its storages set RxDB 17's premium flag at import and when called, so the 13-collection cap never applies.
    • @tallyui/pos:
      • Its rxdb peer is now ~17.5.0.
      • Opening pos_orders rejects with PosOrderOpenClosedError when the database closes during a migration: RxDB 17.5.0 cancels the migration on close. The open first waits for any write already in flight, so none reaches a closed store.
      • An open that needs no migration resolves only once RxDB allows writes, so a sale saved straight after it is never refused with COL25.
    • @tallyui/database:
      • createTallyDatabase returns an RxDB 17 database.
      • In development it adds RxDB's dev-mode plugin when a database is created, not at import.
    • Stored data: a till's SQLite data written by RxDB 16.21.1 opens unchanged under 17.5.0, and migrates its schema versions.

    Upgrade notes

    • Storage is one-way. Once a till has opened this version, pos_orders is at schema version 4, and an older build (such as @tallyui/pos 2.0.0 on RxDB 16.21.1) opens it without an error but shows no orders, so it sends none of the pending ones until the till is upgraded again. Nothing is deleted: the next upgrade recovers every order, including a sale rung during the rollback. Never roll an app back across this version, and never re-ring sales it hides: a re-rung sale is a second sale, and the upgrade sends both. See ADR-069 in docs/DECISIONS.md.
    • Web apps ship the 17.5.0 storage worker with the 17.5.0 main thread. A cached 16.x worker with a 17.5.0 main thread is untested and unsupported.
    • Apps pin rxdb and rxdb-premium to exactly 17.5.0.
    • RxDB 17 defaults a replication's toggleOnDocumentVisible to true (16.21.1: false). It then resyncs when the tab becomes visible, and no longer simulates activity to keep a hidden tab awake, so a browser may throttle a hidden tab's pull. RxDB pauses a hidden tab's replication only when that tab isn't the leader; a single-instance database is always the leader (read in 17.5.0's plugins/replication source, not tested).

Minor Changes

  • 3332558: Tell the three start failures apart (#293), each with its own sentence for the cashier. Storage unavailable, as in a Safari private window where the browser gives the worker no usable OPFS, is the new StorageUnavailableError, recognised with isStorageUnavailableError. Another tab holding the database is recognised with the new isStorageHeldError. A stale worker stays isRxdbRemoteVersionMismatch from @tallyui/core (RM1). isStorageWorkerStartError still means any failed start except storage unavailable, and every start error carries its cause's name and message in its own message.

Patch Changes

  • 1f4d0ab: isStorageWorkerStartError and isStorageWorkerFailure also recognise RxDB's RM1, a stale storage worker built on another RxDB version (for example a cached old worker after an upgrade), so apps show their reload advice for it. Both call the new isRxdbRemoteVersionMismatch in @tallyui/core, which recognises RM1 by structure only: an RxError's own code, or the remote storage's could not create instance wrapping of an RxError's JSON. @tallyui/storage-sqlite now has @tallyui/core as a peer dependency.
  • f96d185: Make the SQLite handle type the minimal interface used by the adapter, accepting expo-sqlite 16's SQLiteDatabase.

@tallyui/pos

Major Changes

  • 6673faf: RxDB 17.5.0.

    • @tallyui/storage-sqlite:
      • Its rxdb-premium peer is now 17.5.0. Apps install rxdb-premium@17.5.0 together with rxdb@17.5.0.
      • Its storages set RxDB 17's premium flag at import and when called, so the 13-collection cap never applies.
    • @tallyui/pos:
      • Its rxdb peer is now ~17.5.0.
      • Opening pos_orders rejects with PosOrderOpenClosedError when the database closes during a migration: RxDB 17.5.0 cancels the migration on close. The open first waits for any write already in flight, so none reaches a closed store.
      • An open that needs no migration resolves only once RxDB allows writes, so a sale saved straight after it is never refused with COL25.
    • @tallyui/database:
      • createTallyDatabase returns an RxDB 17 database.
      • In development it adds RxDB's dev-mode plugin when a database is created, not at import.
    • Stored data: a till's SQLite data written by RxDB 16.21.1 opens unchanged under 17.5.0, and migrates its schema versions.

    Upgrade notes

    • Storage is one-way. Once a till has opened this version, pos_orders is at schema version 4, and an older build (such as @tallyui/pos 2.0.0 on RxDB 16.21.1) opens it without an error but shows no orders, so it sends none of the pending ones until the till is upgraded again. Nothing is deleted: the next upgrade recovers every order, including a sale rung during the rollback. Never roll an app back across this version, and never re-ring sales it hides: a re-rung sale is a second sale, and the upgrade sends both. See ADR-069 in docs/DECISIONS.md.
    • Web apps ship the 17.5.0 storage worker with the 17.5.0 main thread. A cached 16.x worker with a 17.5.0 main thread is untested and unsupported.
    • Apps pin rxdb and rxdb-premium to exactly 17.5.0.
    • RxDB 17 defaults a replication's toggleOnDocumentVisible to true (16.21.1: false). It then resyncs when the tab becomes visible, and no longer simulates activity to keep a hidden tab awake, so a browser may throttle a hidden tab's pull. RxDB pauses a hidden tab's replication only when that tab isn't the leader; a single-instance database is always the leader (read in 17.5.0's plugins/replication source, not tested).

Minor Changes

  • 78d324e: Add useSale.setCustomer and ReceiptData.header.customer, customerTraits, CustomerPicker, generic CustomerSelect/CustomerCard with traits overrides, CustomerForm.showAddress, and the receipt's customer line.

    CustomerSelect rows are now pressable, so choosing a result works on the web (it previously did nothing).

  • 901fa66: Add order.create envelope version 3, its display and tax-rate wire types, and the payload's sessionId and customer reference.

    At capability 3, finalizeOrder copies the receipt's display and taxByRate into the sale. Version 3 sends those figures and the sale's session (stamped or late) as sessionId. Older orders keep their existing envelope version.

    Move pos_orders to schema version 3 with a sessionId index and the optional sentVersion and downgradedFrom fields (declared for the outbox's version fallback, not yet written). Apps must open pos_orders with addPosOrderCollection, which migrates it.

  • bf2d805: order.create version 4 (#286): every discountMinor, the order's and each line's, is tax-exclusive, so their sum still holds. Core accepts version 4 with version 3's fields. The till's envelope builder (toOrderCreateEnvelope) produces version 4 when capped at 4 or more and the order's sentVersion doesn't hold it lower. The till doesn't send version 4 yet: the outbox doesn't pass the server's max, so it still sends version 3 or lower with the same figures, byte-identical.

  • 130d28e: A store that keeps answering 404 is no longer silent. After 3 consecutive 404 answers the order and register outboxes set OutboxState.backendMissing: { since } (when the first of them arrived), and SyncStatus tells the cashier in plain words that sales aren't reaching the online store and are saved on the till, with a detail line for the store owner, instead of showing retrying (status_404); the stuck line shows the same words, with no raw code. SyncStatus takes an optional pluginName (default 'the POS plugin') for that detail. The outboxes keep retrying on their normal backoff, and orders stay pending, so a 404 during a deploy blip recovers on its own. The next answer that isn't a 404 clears it; an offline (network) retry changes nothing. Pass one createBackendNotFound() tracker as backendNotFound to both createOrderOutbox and createRegisterOutbox so their 404s count together and the notice shows once, whichever outbox meets it first; without it, each outbox keeps its own.

  • 9e1032f: One order the store keeps failing no longer stops every later sale. After 5 server-answered failures (offline never counts) the order outbox probes the pending queue one order per backoff interval, oldest first; once the store takes one, the orders whose probes failed are isolated and retried alone, and batching resumes. If no probe gets through, the store is down: nothing is isolated. Retries alternate between the batch (or the probe) and one due isolated order, one request per interval, so neither can starve the other, and isolated orders take turns. An order the store has kept failing for 15 minutes of answered time, on its own clock, is flagged as stuck and stays pending: OutboxState.stuck, with a per-order entry in stuck.orders, useOrderOutbox's stuckCommandIds, needsAttention's stuckCommandIds option, OrdersList's stuck prop (each order with its own time and reason), and SyncStatus's "Not syncing" line. An offline failure pauses every clock, and the store's next answer of any kind resumes them all; a flagged order stays flagged through an offline spell, since a paused clock keeps its answered time. The HTTP transport now reports a request that got no answer in time as timeout, which counts like a 503 and never pauses a clock, and keeps network for a store it could not reach.

  • ca0beac: Fall back to the server's supported order.create version while preserving stored fiscal figures and command IDs. Record the sent version and downgrade in the order audit, and expose command error details.

  • a9cdfc0: Migrate pos_orders to version 4 with optional localWarnings and serverFailures. Record omitted customer details and dropped payment references on the stored order, and show these warnings in the orders list; serverFailures is declared for the next outbox update.

  • 9ffa7c0: The till sends order.create version 4 (#286) to a server that advertises 4. Each order is resent at the version it first went out at: the outbox records sentVersion before an order's first send, so a retry after the store upgrades is byte-identical. With the server's max unknown, the till sends at most 3 and records that. requeue() clears sentVersion and downgradedFrom, since the new commandId chooses afresh.

    pos_orders moves to schema version 5: sentVersion and downgradedFrom accept 1 to 4, and the migration records each order without a sentVersion at its content version (3 with display and taxByRate, else 2 when discounted, else 1). Like version 4, this storage is one-way: an older build opens it but shows no orders (ADR-069).

  • 6bbd1ba: Each sale records the tax rounding its figures were computed with (#287): finalizeOrder writes taxRounding on the stored order, the default (per_order, half_away_from_zero) included, and custom as { granularity: 'custom' }. It is the till's own record and is never sent in order.create. The Z report splits each sale's tax by rate with the strategy that sale recorded, so its rows are the receipts' rows, and its breakdowns.tax_rounding_mixed is true when a session's sales used more than one strategy (a custom sale counts as the default it applied); ClosureSheet then says so, and buildClosureDocument carries the same line ready to print as closure.tax_rounding_note (TAX_ROUNDING_MIXED_NOTE), for the apps' closure templates. PosOrder.taxRounding is now required in the type.

    pos_orders moves to schema version 6: taxRounding is required, and the migration records the default on every older sale, the only rounding any earlier build used. Like version 5, this storage is one-way: an older build opens it but shows no orders, so never roll an app back across it (ADR-069). Before 3.0.0 ships, #242's OPFS upgrade proof is rerun against version 6.

  • 222543b: Add RegisterCommandType, RegisterCommandEnvelope and AnyCommandEnvelope, register payloads and results, and the register server capability. CommandType and CommandEnvelope are unchanged.

    Record the local register_commands ledger through reconcileRegisterCommands, gated in useRegisterSession by its new commands and capabilities options. Commands are recorded but not sent. Medusa reads the register contract.

  • 8141c1c: Guard register commands, movement reasons and register ids, exporting RegisterMovementReasonError and RegisterIdInvalidError. Harden register outbox result handling and batch limits.

    Make CommandBatchRequest generic while preserving its existing default envelope type.

  • c9798a3: The register outbox now sets OutboxState.stuck when the store has kept failing a sent register command for 15 minutes of answered time (STUCK_AFTER_MS, shared with the order outbox; offline gaps pause the clock), so the app can say since when till updates haven't reached the online store. The clock clears when the command is applied or rejected. It is kept in memory only: a restart starts it afresh.

  • 581472f: Add createRegisterOutbox to send stored register commands serially per register. The app starts it only for a store with the register capability.

  • c26ead6: A till no longer sells on a guessed tax rounding. When the store's capabilities read fails (it throws, or answers "unknown", as Vendure's does for a network error or a 5xx), useStoreSettings keeps the settings unresolved instead of falling back to the default rounding. It retries by itself after 5 seconds, then 10, doubling to at most 5 minutes. While it waits, the state is error with a nextRetryAt, and an app shows "Can't reach the store's settings yet. Retrying…". Only a store that reports no rounding, or a connector without capabilities, gets the default. Before this, a failed read on a Vendure store set to per_rate_group_items meant every sale raised figures_mismatch.

  • 0e4c9cc: The "since" a cashier reads is a real time (#253). OutboxState.stuck (both outboxes) gains firstFailedAt: the wall-clock time the first failure of the current stuck run was answered, so an offline gap no longer moves it. since keeps its meaning, the clock's virtual start, and still drives the 15-minute threshold. firstFailedAt is kept in memory only. After a restart it is absent, and SyncStatus and OrdersList show the stored time instead, worded "since about 2:49 AM".

  • a94b255: OutboxState gains rejected?: number: the order outbox publishes the count of pos_orders the store refused (syncStatus: 'rejected') wherever it publishes pending, so it rises when a batch result rejects an order and falls when requeue() sends one again. The register outbox leaves it unset. While rejected is above 0, SyncStatus never says "Sales are up to date.": its whole status line (label, polite live region and iOS announcement) is "1 sale needs attention · The online store refused it. Ask the store owner to look at the till's sync log." or "{n} sales need attention · The online store refused them. Ask the store owner to look at the till's sync log.", in place of the stuck and backend-missing sentences and the sending or retrying line. What else waits stays in the count: "1 sale needs attention, 5 waiting to sync · …" with other sales pending, "…, 2 till updates waiting to sync · …" with till updates, and "…, 5 sales and 2 till updates waiting to sync · …" with both. Below it, "Refused sales stay on this till under Needs attention, each with what to do next.", then the backend-missing detail when the store is missing. With rejected 0 or unset, nothing changes. When the store refused a whole batch (refused set, no sale carrying a code), the status line is the waiting count followed by " · The online store refused the last send. This till will try again with the next sale, or when the app is reopened." in place of the stuck or backend-missing sentence, and the sending or retrying line (which read "Retrying in 0 s.") is hidden; refused sales still outrank it. With only till updates waiting and the register outbox refused, it ends "…try again with the next till update." instead; with a sale waiting, the sales line wins.

  • 5ed6281: The till computes tax with the store's rounding strategy (#287, ADR-071). ServerCapabilities gains taxRounding (core exports TaxRounding): per_order, per_line_items or per_rate_group_items, each with half_away_from_zero or half_up, or custom. Absent, and custom, mean today's per_order with half away from zero. TaxProvider takes rounding and rateCodes (tax class → the backend's rate name, for per_rate_group_items); the order records the strategy as taxRounding, and taxLinesByRate takes it as a fourth argument. The algorithms are in docs/contract/field-kinds.md.

  • 5a204a9: StoreSettings gains a derived taxRounding. useStoreSettings fills it from the context's capabilities, or else from one read of the connector's capabilities() made before the settings are ready, so each sign-in emits the settings once with the rounding known and no later change holds a sale; a failed read or a connector without capabilities gives the default rounding. taxProviderProps passes it to TaxProvider as rounding, so the till rounds tax like the store with no app code (#324). custom passes no rounding, and an explicit rounding prop still wins.

  • 6278d8d: The register outbox can start when its store opens, as the order outbox does (#290). The new useRegisterOutbox({ commands, transport, deviceId, isEnabled?, onResult?, backendNotFound? }) runs createRegisterOutbox over an already-open register_commands collection, and calls start() so that till updates left pending (after a refused batch, for instance) go out when the app reopens. It returns { state, flush }. A new collection or device id restarts the outbox, and commands: null leaves it idle. Apps that create the register outbox themselves should switch to this hook. SyncStatus's till-updates refusal line now ends "…with the next till update, or when the app is reopened.", matching the sales line.

  • e15f389: The Vendure connector supplies its tax rate names, so a per_rate_group_items store groups a sale's tax the way Vendure does (#324). Apps no longer fetch the names themselves.

    • StoreSettings.taxRateCodes (core, optional): the backend's tax rate name per tax class, keyed like taxRatesPpm, including default.
    • vendureStoreSettings reads each rate's name in the tax-rate query it already runs, so no extra request is made. Only the rates taxRatesPpm uses count, and default follows the same default-category rule. taxRateCodes is left out when no names come back.
    • taxProviderProps(settings) passes taxRateCodes to <TaxProvider> as rateCodes.

Patch Changes

  • d6a5073: The outbox freezes an order an older till stored before it first sends it (freezeSentForm, which finalizeOrder uses too):

    • line names, discount labels and payment references are cut to 255 characters, but ids never are;
    • a customer email or id that order.create would refuse is left out;
    • the frozen form is written back, so the receipt and the store see the same bytes.

    So an order stored before the upgrade and still unsent is never refused as invalid_payload.

    New type: SentOrder, an Order whose customer id may be missing. The receipt stage, buildReceiptData and Receipt take it, and a plain Order still fits.

  • 27d736e: CommandWarning gains bridgeMinor on total_mismatch and a new tax_rate_mismatch code, and the till now ignores warning codes it doesn't know (knownWarnings), instead of showing them as a store total.

  • e59ebec: Each line is taxed at its product's tax class, not the store's default (#288). ProductTraits gains an optional getTaxClass(doc, variantId?), the backend's tax class id, a key of StoreSettings.taxRatesPpm. addProduct and addEntryToCart pass it to addLine through the new AddLineInput.taxClass, which the tax context resolves; a connector without the accessor is unchanged (the default rate). TaxProvider taxes a class with no rate at the default rate and warns once per class through the new taxLogger. connector-vendure replicates each variant's taxCategory { id } and implements the accessor, and its store settings give every tax category with no enabled rate in the default zone an explicit 0 rate, as Vendure charges; its product schema goes to version 2, so the products collection is dropped and downloaded again on the first sync after the upgrade.

  • eb203b4: Review follow-ups with no behaviour change (#356, #358):

    • SyncStatus and OrdersList build their "since {time}" and "since about {time}" text with one shared helper.
    • useRegisterOutbox's docs now say when transport() is called, and that the latest isEnabled and onResult are used without restarting the outbox.
  • 8cd7860: A sale the store refuses on its first send is sent once, not twice (found by the Medusa POS app's 3.0.0-next.0 adoption). Before it sends, the outbox stores the order's sent form and version (#300). That write had re-armed the flush, so a refused batch went out again. A write that only records the sent form, for a new sale or for an older one carried over by the pos_orders migrations, is no longer counted as new work. Any other change to a pending sale still sends it.

  • af623c9: The order.create string lengths move from payloadShapeErrors to the new payloadBoundErrors, which precheckCommand calls after the replay lookup, so an applied order resent with a long title replays as duplicate; payloadShapeErrors keeps the types, the customerId and sessionId bounds and the NUL check. useSale applies a tender before logging a dropped reference, and add() refuses a product whose id or v3 tax code finalize would refuse; the tender's reference field caps at 255 characters. finalizeOrder now freezes the sent form (names and discount labels cut, an unsendable customer email or id left out) and toOrderCreateEnvelope sends the stored order unchanged, so every resend is byte-identical.

  • ef2f64e: The shared order.create shape check bounds string lengths and refuses NUL, and so does the v3 fiscal-figures check for its display and tax-code strings. The till cuts long names when it stores the order, refuses over-long pass-through references at finalize (and a payment reference as it's entered), refuses a searched or parked customer whose email or id the server would refuse, and validates the customer email at entry. Tills should ship this clamp before plugins adopt the new bounds, so no till sends a sale the server would now refuse.

  • d225c58: Internal @tallyui/* peer dependencies are published as a caret range (for example ^2.1.0) instead of an exact version. The packages still release together at one version.

  • d329193: The order outbox stores each pending order's stuck clock and isolation in serverFailures and restores them when it starts, so after a restart an order the store keeps refusing no longer holds up the other sales, and its stuck flag keeps its start time.

  • c00e1ea: OrdersList shows a rejected sale's refusal in the cashier's words, one sentence per error code (#269), in both Needs attention and Recent, and never the store's own message. An unknown code, or a rejected sale with no error, shows platform_error's sentence: "The online store refused this sale. Ask the store owner to look at the till's sync log." An idempotency_mismatch shows its sentence ("… Don't send it again; ask the store owner to compare the two.") in place of the old "This sale needs checking against the store before it can be sent again." line, and still has no Retry. The order outbox logs every refusal once to the sync log as "Order refused by the store" with the order id, the code and the store's message: a warning, or an error for unsupported_version (whose log previously used the message itself as its text).

  • 8ae3c53: A register session transition's ledger key now includes its status (session.transition:<sessionId>:<status>:<at>), so a close in the same millisecond as the count before it is queued with its counted, closedBy and approvedBy instead of being skipped (#258).

  • 9885075: Match variant barcodes and SKUs in product search, and skip disabled Vendure variants when reading the product barcode.

  • c48e1dd: Store settings follow-ups to #339 and #341 (#340):

    • A signed-out till is asked to sign in, not shown "Retrying…". When the capabilities read fails with an error only the till can fix, useStoreSettings gives error without nextRetryAt and doesn't retry by itself, so the app prompts. That covers a till-class error (a 401, or a till that needs updating) and a SignInError with code: 'invalid_credentials'; any other sign-in error still waits and retries. Every other failure still waits and retries.
    • A /tally/v1/info JSON body that isn't an object (null, an array or a scalar) is unknown, not the default rounding.
  • df80ead: SyncStatus takes an optional registerState (the register outbox's state): waiting till updates are counted ("1 till update waiting to sync", or named beside the sales), so it never says the sales are up to date while any wait, and with no sale waiting the backend-missing sentence says till updates aren't reaching the online store, or, once registerState.stuck is set, "Till updates haven't reached the online store since {time}. …". The backend-missing detail now reads "This till couldn't find {pluginName} on the online store. …". With nothing waiting, the line is only "Sales are up to date." (it replaces "All sales synced"), with no sending, retrying or problem text after it; if the store is missing, the detail reads "This till couldn't find {pluginName} on the online store the last time it checked. …". The status line's accessibility label is the whole visible line instead of "Sync status". The order and register outboxes let go of a shared backendNotFound tracker on stop() and take it up again on start() or flush().

  • 37aad35: useSale never drops a line tapped while new store settings land (#301). The new sale that new tax settings or currency start on an idle cart now begins in a layout effect, inside the commit that brings those settings, and only when the sale is idle at that moment (no line, cart stage, no save in flight or pending), not as of the last render. Every sale change reads the current order builder, so a call from an older render never reaches a builder that newSale() has replaced. A line tapped once the new settings have committed lands on the new sale, priced with the new settings; a line that reaches the sale before its new sale starts keeps that sale, on its old settings.

  • 4122dc8: order.create v3 omits a malformed session ID (empty or longer than 36 characters) instead of sending it, so the plugin never refuses the sale for it. At capability 3, finalizeOrder refuses a sale whose display lines don't join the order's lines by id and count, or whose display tax mode differs from the order's.

@tallyui/connector-woocommerce

Minor Changes

  • 457162d: One reconcile feed per store session (#307, a release gate). The WooCommerce and Medusa reconcile feeds were module-level singletons, so after a store switch in one runtime, store A's queued tombstones and refetches could reach store B's database.

    • New factories. createWooCommerceConnector(), createMedusaConnector() and createMedusaAdminUserConnector() each build their own feed; createVendureConnector(options) already did. Build a connector per store session, anew on each sign-in or store change.
    • Deprecated exports. woocommerceConnector, medusaConnector, medusaAdminUserConnector and vendureConnector are deprecated: one instance for the whole app can leak queued reconcile work across stores. They are removed in 4.0.
    • A development warning. startReplication warns once when the same adapter object replicates into two collections at once.
    • Refetch budget by requests. refetchBatchSize on the reconcile adapters makes a page that enqueues n refetches take ceil(n / refetchBatchSize) request-budget slots (WooCommerce 100, Medusa 100, Vendure 1,000).
    • WooCommerce 426 errors. A foreign (non-WCPOS) 426 keeps the store's code beside its message, and the message is capped at 200 characters.
  • ce4f796: Replication pull errors are handled according to who can fix them, instead of every error being retried every 5 s forever. A till repeating a rejected token is the traffic a store's security plugin blocks.

    • @tallyui/core:
      • An error class declares fixedBy: 'till' | 'store' with a string code; errorKind(error) returns 'till', 'store' or 'transient'.
      • SyncNotice ({ code, since, fixedBy, software?, minVersion?, fix? }) describes a stopped pull.
      • ConnectorUnauthorizedError is fixed by the till.
    • @tallyui/database startReplication handles the three kinds and returns RxDB's state plus notice$ and resume():
      • till: one request, one notice, then the pull stays stopped until the app calls resume(), after sign-in. The pull stays stopped even when RxDB restarts the loop on page visibility.
      • store: one notice, then one attempt every 5 minutes (or the error's retryAfterMs, up to 1 hour). The notice clears itself on the first success, so a till recovers within 5 minutes of the owner's fix.
      • transient: a doubling delay from retryTime to 5 minutes. It waits at least a valid retryAfterMs (a finite number of zero or more), capped at 1 hour.
    • @tallyui/components: SyncStatus takes an optional pullNotice and tells the cashier in plain words that they can keep selling and who needs to act. It never shows a code, a backend name or a version the notice doesn't carry.
    • @tallyui/connector-woocommerce:
      • WooDateFilterError is fixed by the store, and carries software and minVersion.
      • WooMissingUuidError gains code: 'missing_plugin' and is fixed by the store.
    • @tallyui/connector-vendure: a new VendureTimezoneConfigError (store_misconfigured, with a plain fix) replaces the plain error when the updatedAt probe shows a server that isn't in UTC.
  • ad18929: The WooCommerce connector names its auth failures, so the app can tell "sign in again" apart from "store broken": a 401 or 403 from the product pull rejects with ConnectorUnauthorizedError (re-exported from the connector, as Medusa does), and auth.getHeaders without a WCPOS token throws WooMissingTokenError, a subclass of ConnectorUnauthorizedError, instead of sending Bearer undefined. Other HTTP errors keep their message and class.

  • 136343c: The WooCommerce connector gets a daily reconciliation pass (#248, part B), a safety net for edits the incremental pull can miss: the spring-forward hour, an over-excluding filter, a same-second edit, a shift without X-WP-Total, trashed or unpublished products, and stock written without a modified-time bump.

    • reconcile.catalogue lists the published catalogue with no date filter, comparing date, stock quantity and stock status. It re-reads deletion candidates by id and removes only those that are gone, trashed or unpublished. Everything else it re-pulls through the collection's own pull.
    • replication.products now combines the product pull with the reconcile feed, with legacyKey: 'products', so existing installs keep their checkpoint.
    • A product the store cannot be asked about (no numeric id) is never deleted.
    • The WCPOS bulk-ID fast path is read from wcpos/v2/status capabilities (products_id_fast_path). It stays dormant until wcpos/woocommerce-pos#2113 ships.
    • @tallyui/database: the catalogue runner's gate check has a 60-second floor, so a bad interval can no longer re-arm it on every tick.
  • ddd9e85: The WooCommerce catalogue reconcile uses the WCPOS products fast path (#313). When wcpos/v2/status lists products_id_fast_path in capabilities, the whole catalogue is listed in one request (per_page=-1, _fields=id,date_modified_gmt,stock_quantity,stock_status) instead of pages of 100. A store that refuses it, or answers with something that is not a list, is listed page by page in the same pass.

    • Keyed on the remote id: both WooCommerce listings key on the numeric product id, never the till-local uuid.
    • CatalogueReconcileAdapter.matchKey (core, optional): an adapter whose listing carries no primary key declares how to match a local document. The catalogue runner indexes the local documents by it for each pass. Deletion is unchanged: confirmGone, then the mass-delete brake, by primary key.
    • remote on the keyed reconcile feed (core, optional): a listed product the till does not hold yet is matched back by its remote id, so it is delivered rather than dropped.
  • fb4b983: A jwt_auth_* 403 is now WooTokenRefusedError (store_misconfigured, fixedBy: 'store', with a fix), replacing WooPluginUpdateRequiredError (#360). The old error told the store owner to update to WCPOS 1.10.8, but the same 403 also arrives on 1.10.8 and later, so the new one names no version: "The store refused the sign-in token (403). Ask the store owner to check the JWT Authentication plugin's settings, or pair the till again." SyncStatus shows "a setting on the online store needs changing" with the fix. Breaking for importers: WooPluginUpdateRequiredError is no longer exported.

  • 508876e: The WooCommerce connector now requires WooCommerce 5.8 or later (for modified_after on the products route; dates_are_gmt arrived in 5.4).

    • The GMT question goes to the store. The product pull asks the store whether anything changed since the last pass, in GMT: the mark request sends modified_after=<last mark>&dates_are_gmt=true, and an empty answer ends the poll. Before, the pull compared the first row of a local-time sort, so in a daylight-saving fall-back hour an edit could wait until the next one.
    • Stores that ignore the filter are refused. If a store returns a product outside the requested window (WooCommerce before 5.8, or a proxy that drops the parameter), the pull throws the new WooDateFilterError (code: 'unsupported_store') instead of trusting it.
    • Dates carry no offset. Dates are sent as GMT digits without an offset, because WordPress parses an offset-bearing date in the site's timezone before it compares it with the GMT column.
    • The connector has a README.
  • 3b206d6: The connector now authenticates with a WCPOS bearer token and the X-WCPOS: 1 header against <site>/wp-json/wcpos/v2 (WCPOS Free 1.10.0 or later); the consumer key and secret fields are removed; a pulled product without a uuid throws WooMissingUuidError.

  • cbf26fd: The WooCommerce connector sends WCPOS's protocol signal, so a WCPOS 2.0 store does not refuse it (#296). Every request carries X-WCPOS-Protocol: 2 and X-WCPOS-Client: tallyui/<connector version>. WCPOS's 2.0 gate refuses POS-marked wcpos/v2 requests without protocol 2, and protocol 2 is a pure declaration the connector already conforms to. The headers are harmless on WCPOS 1.x.

    If a store still answers 426 (wcpos_update_required), the new WooTillUpdateRequiredError (till_update_required, fixed by the till) stops the product pull after one request. SyncStatus then tells the cashier: "Products aren't updating: this till needs updating."

Patch Changes

  • 7fee0c1: A WooCommerce product whose uuid changed in the store is replaced on the till in one pass (#331). The till delivers it under its new uuid and removes the old copy. Before this fix, it removed the old copy and dropped the new one, so the product was missing until the next daily check, and it was removed without the usual by-id check.

    • The reconcile feed: when an entry that has a local copy is fetched back under a different primary key but the same remote id, the feed delivers that document as well as removing the old copy.
    • combinePullAdapters takes an optional key for resolving duplicates across its sub-adapters. It defaults to doc.id, as before. WooCommerce passes the uuid (its primary key): two documents that share a store id, such as a product's new copy and its old copy's removal, must both reach the collection.
  • d225c58: Internal @tallyui/* peer dependencies are published as a caret range (for example ^2.1.0) instead of an exact version. The packages still release together at one version.

  • 1223353: A stale duplicate copy of a store product no longer stays on the till for good (#369). When two local products share one store id, the catalogue check now makes the copy its index did not pick a deletion candidate, and logs a duplicate event with the code duplicate_match_key. The copy is tombstoned only when confirmGone proves the store doesn't back it, and the mass-delete brake still applies. WooCommerce's confirmGone now also confirms a local whose id is live but whose uuid isn't the store's for that id. The store listing is the source of truth, and a later pull restores anything the store still backs.

  • 6673faf: RxDB 17.5.0.

    • @tallyui/storage-sqlite:
      • Its rxdb-premium peer is now 17.5.0. Apps install rxdb-premium@17.5.0 together with rxdb@17.5.0.
      • Its storages set RxDB 17's premium flag at import and when called, so the 13-collection cap never applies.
    • @tallyui/pos:
      • Its rxdb peer is now ~17.5.0.
      • Opening pos_orders rejects with PosOrderOpenClosedError when the database closes during a migration: RxDB 17.5.0 cancels the migration on close. The open first waits for any write already in flight, so none reaches a closed store.
      • An open that needs no migration resolves only once RxDB allows writes, so a sale saved straight after it is never refused with COL25.
    • @tallyui/database:
      • createTallyDatabase returns an RxDB 17 database.
      • In development it adds RxDB's dev-mode plugin when a database is created, not at import.
    • Stored data: a till's SQLite data written by RxDB 16.21.1 opens unchanged under 17.5.0, and migrates its schema versions.

    Upgrade notes

    • Storage is one-way. Once a till has opened this version, pos_orders is at schema version 4, and an older build (such as @tallyui/pos 2.0.0 on RxDB 16.21.1) opens it without an error but shows no orders, so it sends none of the pending ones until the till is upgraded again. Nothing is deleted: the next upgrade recovers every order, including a sale rung during the rollback. Never roll an app back across this version, and never re-ring sales it hides: a re-rung sale is a second sale, and the upgrade sends both. See ADR-069 in docs/DECISIONS.md.
    • Web apps ship the 17.5.0 storage worker with the 17.5.0 main thread. A cached 16.x worker with a 17.5.0 main thread is untested and unsupported.
    • Apps pin rxdb and rxdb-premium to exactly 17.5.0.
    • RxDB 17 defaults a replication's toggleOnDocumentVisible to true (16.21.1: false). It then resyncs when the tab becomes visible, and no longer simulates activity to keep a hidden tab awake, so a browser may throttle a hidden tab's pull. RxDB pauses a hidden tab's replication only when that tab isn't the leader; a single-instance database is always the leader (read in 17.5.0's plugins/replication source, not tested).
  • 3cf5452: Follow-ups to the 401/403 split (#345):

    • Vendure: a signed-in user missing a permission (confirmed by the session probe) is now ConnectorUnauthorizedError with status: 403, the forbidden notice, instead of a plain transient error.
    • WooCommerce: a 403 from the JWT-auth plugin (jwt_auth_*) reaches the till only with a valid token on WCPOS 1.10.0–1.10.7 (wcpos/woocommerce-pos#1863). It is now WooPluginUpdateRequiredError (unsupported_store, WCPOS 1.10.8): the store owner updates WCPOS, and the till is never sent into a sign-in loop.
    • ConnectorUnauthorizedError: only a 403 is forbidden. A caller that omits status gets unauthorized, as before 3.0.
    • Docs: the customer picker's onError, the replication guide's error classes, and the connector comments now say that only a 401 means sign in again.
  • 8cf3ea4: A till tells "sign in again" apart from "signed in, but not allowed" (found by the Medusa POS app's adoption).

    • ConnectorUnauthorizedError.status is now required, typed 401 | 403, and set by meaning at every connector.
      • 401: the credentials are not accepted, so sign in again. code: 'unauthorized', fixed by the till.
      • 403: the till is signed in but not allowed. code: 'forbidden', fixed by the store.
      • Vendure answers a signed-out session with 403 too. Its connector checks who is signed in first, so a confirmed sign-out is always 401.
    • A 403 on the pull gives the forbidden notice, never a sign-out. The pull retries on the store schedule and clears by itself once the store owner grants the permission. SyncStatus shows "Products aren't updating: your account isn't allowed to do this on this store." with "You can keep selling. Ask the store owner."
    • The customer picker shows "Your account isn't allowed to do this on this store. Ask the store owner." for a 403, instead of asking the cashier to sign in again.
  • d9ecbb8: Only WCPOS's own protocol gate counts as "this till needs updating" (#302):

    • The plugin's gate: a 426 whose body carries code: "wcpos_update_required" still raises WooTillUpdateRequiredError.
    • Any other 426 (from a proxy or another plugin, or with no or another body) is now a transient error, retried with backoff, so it never tells a cashier to update the till.

    The built connector now bundles only its version from package.json, not the whole file.

  • 154e552: The product pull sends dates_are_gmt=true with every modified_after, so WooCommerce compares the GMT checkpoint against post_modified_gmt instead of the store's local time; on a store west of UTC the next pull no longer skips edits made in between.

  • 87087f1: When the catalogue check's fast path fails, or answers with something that is not a list, the page-by-page fallback now starts with an empty page (#331). Its first request then takes its own request-budget slot, as the status read does, instead of sharing the failed fast-path request's slot. A pass that falls back reports one more page.

  • e0f0afb: The WooCommerce product pull makes one request per quiet poll in two more cases: after the most recently edited product is trashed (the store's newest product is then older than the pull's lower bound; it was 3 requests), and on a store with no products (it was 4). The stored restarts counter is gone: every shrink of the window restarts the pass, bounded by the per-call request budget. A stored checkpoint that still carries restarts keeps working.

  • a0256e1: The product pull no longer skips products that share a date_modified_gmt second across a page boundary. It pulls in passes, like the Medusa connector: each pass fixes an inclusive lower bound (modified_after one second earlier), pages that window by product id with an offset, restarts if X-WP-Total drops between pages (after three restarts, a fresh pass starts in the same call), ends on a short or empty page when a proxy strips X-WP-Total, and moves the lower bound to the newest date_modified_gmt in the store when the pass began. Because RxDB does not store the checkpoint of a pull that returns no documents, the handler never carries state in an empty result: a pass that ends on an empty page chains into the next pass in the same call, and each call makes at most four requests. When nothing has changed since the last pass, the pull makes one small request and returns nothing. WooProductCheckpoint is now { modified, offset, pass_mark?, pass_count?, restarts? }; a stored { id, modified } checkpoint is read as the start of a pass.

  • b8aba84: The product pull marks every product whose status is not publish (draft, pending, private, or none) as _deleted, so RxDB removes it from the POS catalogue, and a product that is published again comes back. The pull still reads every status through the modified-date cursor and sends no status parameter, so a product that goes from published to draft is seen and removed rather than left on the till.

@tallyui/connector-medusa

Minor Changes

  • faa7cda: Expose ConnectorUnauthorizedError for expired or rejected stored credentials in Vendure and Medusa requests.

  • 0d04d13: Add neutral Customer, CustomerInput and CustomerServiceError exports and optional online-only customer search, create and get connector methods.

    Implement customer search, create and get for Medusa's admin-user connector.

  • 457162d: One reconcile feed per store session (#307, a release gate). The WooCommerce and Medusa reconcile feeds were module-level singletons, so after a store switch in one runtime, store A's queued tombstones and refetches could reach store B's database.

    • New factories. createWooCommerceConnector(), createMedusaConnector() and createMedusaAdminUserConnector() each build their own feed; createVendureConnector(options) already did. Build a connector per store session, anew on each sign-in or store change.
    • Deprecated exports. woocommerceConnector, medusaConnector, medusaAdminUserConnector and vendureConnector are deprecated: one instance for the whole app can leak queued reconcile work across stores. They are removed in 4.0.
    • A development warning. startReplication warns once when the same adapter object replicates into two collections at once.
    • Refetch budget by requests. refetchBatchSize on the reconcile adapters makes a page that enqueues n refetches take ceil(n / refetchBatchSize) request-budget slots (WooCommerce 100, Medusa 100, Vendure 1,000).
    • WooCommerce 426 errors. A foreign (non-WCPOS) 426 keeps the store's code beside its message, and the message is capped at 200 characters.
  • 7d1bc98: Add parseTaxRounding and parseInfoCapabilities, which read /tally/v1/info including its top-level taxRounding (#287). The Medusa connector's capability read now carries the store's taxRounding.

Patch Changes

  • eb5a032: A /tally/v1/info answer that says nothing about the store no longer means the default tax rounding (a follow-up to #339).

    • Unknown: a 2xx that is not JSON, and a taxRounding value that is present but malformed, now read as "unknown" (undefined), like a network failure or a 5xx. The till's store settings wait and retry instead of selling on a guessed rounding.
    • Unchanged: a 404 still means an older plugin (orderCreate: 1, the default rounding), and so does a well-formed body with no taxRounding key.
    • Type change: parseInfoCapabilities now returns ServerCapabilities | undefined. It is undefined when the body carries a malformed taxRounding.
  • 2ecaa36: A replication adapter can set pull.batchSize, and the Medusa connector pulls 500 products per page.

  • d225c58: Internal @tallyui/* peer dependencies are published as a caret range (for example ^2.1.0) instead of an exact version. The packages still release together at one version.

  • 222543b: Add RegisterCommandType, RegisterCommandEnvelope and AnyCommandEnvelope, register payloads and results, and the register server capability. CommandType and CommandEnvelope are unchanged.

    Record the local register_commands ledger through reconcileRegisterCommands, gated in useRegisterSession by its new commands and capabilities options. Commands are recorded but not sent. Medusa reads the register contract.

  • 6673faf: RxDB 17.5.0.

    • @tallyui/storage-sqlite:
      • Its rxdb-premium peer is now 17.5.0. Apps install rxdb-premium@17.5.0 together with rxdb@17.5.0.
      • Its storages set RxDB 17's premium flag at import and when called, so the 13-collection cap never applies.
    • @tallyui/pos:
      • Its rxdb peer is now ~17.5.0.
      • Opening pos_orders rejects with PosOrderOpenClosedError when the database closes during a migration: RxDB 17.5.0 cancels the migration on close. The open first waits for any write already in flight, so none reaches a closed store.
      • An open that needs no migration resolves only once RxDB allows writes, so a sale saved straight after it is never refused with COL25.
    • @tallyui/database:
      • createTallyDatabase returns an RxDB 17 database.
      • In development it adds RxDB's dev-mode plugin when a database is created, not at import.
    • Stored data: a till's SQLite data written by RxDB 16.21.1 opens unchanged under 17.5.0, and migrates its schema versions.

    Upgrade notes

    • Storage is one-way. Once a till has opened this version, pos_orders is at schema version 4, and an older build (such as @tallyui/pos 2.0.0 on RxDB 16.21.1) opens it without an error but shows no orders, so it sends none of the pending ones until the till is upgraded again. Nothing is deleted: the next upgrade recovers every order, including a sale rung during the rollback. Never roll an app back across this version, and never re-ring sales it hides: a re-rung sale is a second sale, and the upgrade sends both. See ADR-069 in docs/DECISIONS.md.
    • Web apps ship the 17.5.0 storage worker with the 17.5.0 main thread. A cached 16.x worker with a 17.5.0 main thread is untested and unsupported.
    • Apps pin rxdb and rxdb-premium to exactly 17.5.0.
    • RxDB 17 defaults a replication's toggleOnDocumentVisible to true (16.21.1: false). It then resyncs when the tab becomes visible, and no longer simulates activity to keep a hidden tab awake, so a browser may throttle a hidden tab's pull. RxDB pauses a hidden tab's replication only when that tab isn't the leader; a single-instance database is always the leader (read in 17.5.0's plugins/replication source, not tested).
  • 8cf3ea4: A till tells "sign in again" apart from "signed in, but not allowed" (found by the Medusa POS app's adoption).

    • ConnectorUnauthorizedError.status is now required, typed 401 | 403, and set by meaning at every connector.
      • 401: the credentials are not accepted, so sign in again. code: 'unauthorized', fixed by the till.
      • 403: the till is signed in but not allowed. code: 'forbidden', fixed by the store.
      • Vendure answers a signed-out session with 403 too. Its connector checks who is signed in first, so a confirmed sign-out is always 401.
    • A 403 on the pull gives the forbidden notice, never a sign-out. The pull retries on the store schedule and clears by itself once the store owner grants the permission. SyncStatus shows "Products aren't updating: your account isn't allowed to do this on this store." with "You can keep selling. Ask the store owner."
    • The customer picker shows "Your account isn't allowed to do this on this store. Ask the store owner." for a 403, instead of asking the cashier to sign in again.

@tallyui/connector-shopify

Patch Changes

  • d225c58: Internal @tallyui/* peer dependencies are published as a caret range (for example ^2.1.0) instead of an exact version. The packages still release together at one version.

  • 6673faf: RxDB 17.5.0.

    • @tallyui/storage-sqlite:
      • Its rxdb-premium peer is now 17.5.0. Apps install rxdb-premium@17.5.0 together with rxdb@17.5.0.
      • Its storages set RxDB 17's premium flag at import and when called, so the 13-collection cap never applies.
    • @tallyui/pos:
      • Its rxdb peer is now ~17.5.0.
      • Opening pos_orders rejects with PosOrderOpenClosedError when the database closes during a migration: RxDB 17.5.0 cancels the migration on close. The open first waits for any write already in flight, so none reaches a closed store.
      • An open that needs no migration resolves only once RxDB allows writes, so a sale saved straight after it is never refused with COL25.
    • @tallyui/database:
      • createTallyDatabase returns an RxDB 17 database.
      • In development it adds RxDB's dev-mode plugin when a database is created, not at import.
    • Stored data: a till's SQLite data written by RxDB 16.21.1 opens unchanged under 17.5.0, and migrates its schema versions.

    Upgrade notes

    • Storage is one-way. Once a till has opened this version, pos_orders is at schema version 4, and an older build (such as @tallyui/pos 2.0.0 on RxDB 16.21.1) opens it without an error but shows no orders, so it sends none of the pending ones until the till is upgraded again. Nothing is deleted: the next upgrade recovers every order, including a sale rung during the rollback. Never roll an app back across this version, and never re-ring sales it hides: a re-rung sale is a second sale, and the upgrade sends both. See ADR-069 in docs/DECISIONS.md.
    • Web apps ship the 17.5.0 storage worker with the 17.5.0 main thread. A cached 16.x worker with a 17.5.0 main thread is untested and unsupported.
    • Apps pin rxdb and rxdb-premium to exactly 17.5.0.
    • RxDB 17 defaults a replication's toggleOnDocumentVisible to true (16.21.1: false). It then resyncs when the tab becomes visible, and no longer simulates activity to keep a hidden tab awake, so a browser may throttle a hidden tab's pull. RxDB pauses a hidden tab's replication only when that tab isn't the leader; a single-instance database is always the leader (read in 17.5.0's plugins/replication source, not tested).

@tallyui/connector-vendure

Minor Changes

  • faa7cda: Expose ConnectorUnauthorizedError for expired or rejected stored credentials in Vendure and Medusa requests.

  • 457162d: One reconcile feed per store session (#307, a release gate). The WooCommerce and Medusa reconcile feeds were module-level singletons, so after a store switch in one runtime, store A's queued tombstones and refetches could reach store B's database.

    • New factories. createWooCommerceConnector(), createMedusaConnector() and createMedusaAdminUserConnector() each build their own feed; createVendureConnector(options) already did. Build a connector per store session, anew on each sign-in or store change.
    • Deprecated exports. woocommerceConnector, medusaConnector, medusaAdminUserConnector and vendureConnector are deprecated: one instance for the whole app can leak queued reconcile work across stores. They are removed in 4.0.
    • A development warning. startReplication warns once when the same adapter object replicates into two collections at once.
    • Refetch budget by requests. refetchBatchSize on the reconcile adapters makes a page that enqueues n refetches take ceil(n / refetchBatchSize) request-budget slots (WooCommerce 100, Medusa 100, Vendure 1,000).
    • WooCommerce 426 errors. A foreign (non-WCPOS) 426 keeps the store's code beside its message, and the message is capped at 200 characters.
  • ce4f796: Replication pull errors are handled according to who can fix them, instead of every error being retried every 5 s forever. A till repeating a rejected token is the traffic a store's security plugin blocks.

    • @tallyui/core:
      • An error class declares fixedBy: 'till' | 'store' with a string code; errorKind(error) returns 'till', 'store' or 'transient'.
      • SyncNotice ({ code, since, fixedBy, software?, minVersion?, fix? }) describes a stopped pull.
      • ConnectorUnauthorizedError is fixed by the till.
    • @tallyui/database startReplication handles the three kinds and returns RxDB's state plus notice$ and resume():
      • till: one request, one notice, then the pull stays stopped until the app calls resume(), after sign-in. The pull stays stopped even when RxDB restarts the loop on page visibility.
      • store: one notice, then one attempt every 5 minutes (or the error's retryAfterMs, up to 1 hour). The notice clears itself on the first success, so a till recovers within 5 minutes of the owner's fix.
      • transient: a doubling delay from retryTime to 5 minutes. It waits at least a valid retryAfterMs (a finite number of zero or more), capped at 1 hour.
    • @tallyui/components: SyncStatus takes an optional pullNotice and tells the cashier in plain words that they can keep selling and who needs to act. It never shows a code, a backend name or a version the notice doesn't carry.
    • @tallyui/connector-woocommerce:
      • WooDateFilterError is fixed by the store, and carries software and minVersion.
      • WooMissingUuidError gains code: 'missing_plugin' and is fixed by the store.
    • @tallyui/connector-vendure: a new VendureTimezoneConfigError (store_misconfigured, with a plain fix) replaces the plain error when the updatedAt probe shows a server that isn't in UTC.
  • 7d1bc98: Read the store's capabilities and taxRounding from the Vendure plugin's /tally/v1/info (#287): vendureSignIn returns capabilities, and the connector gains capabilities(context) for a restored session or an API key.

  • e15f389: The Vendure connector supplies its tax rate names, so a per_rate_group_items store groups a sale's tax the way Vendure does (#324). Apps no longer fetch the names themselves.

    • StoreSettings.taxRateCodes (core, optional): the backend's tax rate name per tax class, keyed like taxRatesPpm, including default.
    • vendureStoreSettings reads each rate's name in the tax-rate query it already runs, so no extra request is made. Only the rates taxRatesPpm uses count, and default follows the same default-category rule. taxRateCodes is left out when no names come back.
    • taxProviderProps(settings) passes taxRateCodes to <TaxProvider> as rateCodes.

Patch Changes

  • eb5a032: A /tally/v1/info answer that says nothing about the store no longer means the default tax rounding (a follow-up to #339).

    • Unknown: a 2xx that is not JSON, and a taxRounding value that is present but malformed, now read as "unknown" (undefined), like a network failure or a 5xx. The till's store settings wait and retry instead of selling on a guessed rounding.
    • Unchanged: a 404 still means an older plugin (orderCreate: 1, the default rounding), and so does a well-formed body with no taxRounding key.
    • Type change: parseInfoCapabilities now returns ServerCapabilities | undefined. It is undefined when the body carries a malformed taxRounding.
  • e59ebec: Each line is taxed at its product's tax class, not the store's default (#288). ProductTraits gains an optional getTaxClass(doc, variantId?), the backend's tax class id, a key of StoreSettings.taxRatesPpm. addProduct and addEntryToCart pass it to addLine through the new AddLineInput.taxClass, which the tax context resolves; a connector without the accessor is unchanged (the default rate). TaxProvider taxes a class with no rate at the default rate and warns once per class through the new taxLogger. connector-vendure replicates each variant's taxCategory { id } and implements the accessor, and its store settings give every tax category with no enabled rate in the default zone an explicit 0 rate, as Vendure charges; its product schema goes to version 2, so the products collection is dropped and downloaded again on the first sync after the upgrade.

  • d225c58: Internal @tallyui/* peer dependencies are published as a caret range (for example ^2.1.0) instead of an exact version. The packages still release together at one version.

  • 6673faf: RxDB 17.5.0.

    • @tallyui/storage-sqlite:
      • Its rxdb-premium peer is now 17.5.0. Apps install rxdb-premium@17.5.0 together with rxdb@17.5.0.
      • Its storages set RxDB 17's premium flag at import and when called, so the 13-collection cap never applies.
    • @tallyui/pos:
      • Its rxdb peer is now ~17.5.0.
      • Opening pos_orders rejects with PosOrderOpenClosedError when the database closes during a migration: RxDB 17.5.0 cancels the migration on close. The open first waits for any write already in flight, so none reaches a closed store.
      • An open that needs no migration resolves only once RxDB allows writes, so a sale saved straight after it is never refused with COL25.
    • @tallyui/database:
      • createTallyDatabase returns an RxDB 17 database.
      • In development it adds RxDB's dev-mode plugin when a database is created, not at import.
    • Stored data: a till's SQLite data written by RxDB 16.21.1 opens unchanged under 17.5.0, and migrates its schema versions.

    Upgrade notes

    • Storage is one-way. Once a till has opened this version, pos_orders is at schema version 4, and an older build (such as @tallyui/pos 2.0.0 on RxDB 16.21.1) opens it without an error but shows no orders, so it sends none of the pending ones until the till is upgraded again. Nothing is deleted: the next upgrade recovers every order, including a sale rung during the rollback. Never roll an app back across this version, and never re-ring sales it hides: a re-rung sale is a second sale, and the upgrade sends both. See ADR-069 in docs/DECISIONS.md.
    • Web apps ship the 17.5.0 storage worker with the 17.5.0 main thread. A cached 16.x worker with a 17.5.0 main thread is untested and unsupported.
    • Apps pin rxdb and rxdb-premium to exactly 17.5.0.
    • RxDB 17 defaults a replication's toggleOnDocumentVisible to true (16.21.1: false). It then resyncs when the tab becomes visible, and no longer simulates activity to keep a hidden tab awake, so a browser may throttle a hidden tab's pull. RxDB pauses a hidden tab's replication only when that tab isn't the leader; a single-instance database is always the leader (read in 17.5.0's plugins/replication source, not tested).
  • 9885075: Match variant barcodes and SKUs in product search, and skip disabled Vendure variants when reading the product barcode.

  • 3cf5452: Follow-ups to the 401/403 split (#345):

    • Vendure: a signed-in user missing a permission (confirmed by the session probe) is now ConnectorUnauthorizedError with status: 403, the forbidden notice, instead of a plain transient error.
    • WooCommerce: a 403 from the JWT-auth plugin (jwt_auth_*) reaches the till only with a valid token on WCPOS 1.10.0–1.10.7 (wcpos/woocommerce-pos#1863). It is now WooPluginUpdateRequiredError (unsupported_store, WCPOS 1.10.8): the store owner updates WCPOS, and the till is never sent into a sign-in loop.
    • ConnectorUnauthorizedError: only a 403 is forbidden. A caller that omits status gets unauthorized, as before 3.0.
    • Docs: the customer picker's onError, the replication guide's error classes, and the connector comments now say that only a 401 means sign in again.
  • 8cf3ea4: A till tells "sign in again" apart from "signed in, but not allowed" (found by the Medusa POS app's adoption).

    • ConnectorUnauthorizedError.status is now required, typed 401 | 403, and set by meaning at every connector.
      • 401: the credentials are not accepted, so sign in again. code: 'unauthorized', fixed by the till.
      • 403: the till is signed in but not allowed. code: 'forbidden', fixed by the store.
      • Vendure answers a signed-out session with 403 too. Its connector checks who is signed in first, so a confirmed sign-out is always 401.
    • A 403 on the pull gives the forbidden notice, never a sign-out. The pull retries on the store schedule and clears by itself once the store owner grants the permission. SyncStatus shows "Products aren't updating: your account isn't allowed to do this on this store." with "You can keep selling. Ask the store owner."
    • The customer picker shows "Your account isn't allowed to do this on this store. Ask the store owner." for a 403, instead of asking the cashier to sign in again.
  • 9cd4024: A GraphQL FORBIDDEN answer throws ConnectorUnauthorizedError only when a probe ({ activeAdministrator { id } }, same headers) finds nobody signed in (#274). With a live administrator it is a plain Error naming the missing permission, and a failed probe is a plain, transient Error, so a missing permission no longer signs the till out into a sign-in loop.

2.0.0

@tallyui/core

Minor Changes

  • #23 53af670 Thanks @kilbot! - Breaking: the cart and checkout components are presentational and take Money. CartLine takes name, quantity, unitPrice and lineTotal. CartTotal takes subtotal, taxLines, discount and total. CashTendered and ChangeDisplay take Money amounts. All of them format with formatMoney, so there is no getPrice, float arithmetic or hard-coded '$'. CartPanel is generic, and CartLineItem is removed. Totals come from @tallyui/pos; the components no longer compute tax. The cash input keeps the text as typed and emits integer minor units.

    @tallyui/core adds moneyFromDecimalString, which parses typed decimal text into Money using integer arithmetic.

  • #54 50317c8 Thanks @kilbot! - Connectors can sign a user in: ConnectorAuth gains an optional signIn(baseUrl, { email, password }, init?) that resolves to a SignInResult (token, optional ISO 8601 expiresAt) and rejects with a SignInError whose code is invalid_credentials, unsupported or failed. The app stores the token and passes it back to getHeaders as token.

    Vendure's auth gains a sign-in flow: it runs the Admin API login mutation and takes the token from the vendure-auth-token header (the server's tokenMethod must include 'bearer'). Its fields are now url, email, password and an optional channel_token. getHeaders sends credentials.api_key as vendure-api-key, otherwise credentials.token as a Bearer token, plus vendure-token when channel_token is set. The old auth_token credential is still accepted as a deprecated alias for token.

    Medusa's medusaAdminUserAuth signs in through POST /auth/user/emailpass and reads expiresAt from the JWT's exp. medusaSecretKeyAuth has no sign-in.

  • #58 3e09957 Thanks @kilbot! - Add combinePullAdapters to @tallyui/core: it combines several pull adapters into the one adapter a collection replicates with, calling them one after another with a checkpoint per sub-adapter. Two replications on one collection can skip each other's pulled versions, so run one per collection.

    Vendure's replication.products now includes a variant feed that re-delivers parent products whose variants changed. Vendure does not bump Product.updatedAt on a variant price or stock edit, so the product feed alone misses those changes. An existing install's product-feed checkpoint carries over; the variant feed runs one full pass on first sync.

  • #18 a219ae0 Thanks @kilbot! - Adds the TallyUI Sync Protocol command contract: the CommandEnvelope, CommandResult, CommandWarning and OrderCreatePayload types (with their line and payment types), the batch request and response types, the constants COMMANDS_PATH, PROTOCOL_HEADER, PROTOCOL_VERSION and MAX_COMMANDS_PER_BATCH, and the isCommandBatchResponse guard. These are the shapes pinned in ADR-038 and ADR-039, shared by the POS outbox and backend plugins.

  • #115 4df9be4 Thanks @kilbot! - Discounts are pre-tax (ADR-062). An order discount is allocated across the lines in proportion to their own-mode amounts (the new allocateOrderDiscount, largest-remainder rounding), each line carries its share in orderDiscountMinor and is taxed after it, so an order discount now lowers the tax instead of coming off the total after tax. A discounted order.create is version 2, with discountMinor on each discounted line and on the payload; a discount-free payload stays version 1, byte-identical. finalize still rejects discounts until the plugins honour version 2. Receipt lines show their discountMinor. Stacked percentage order discounts are additive, each computed on the pre-order-discount base rather than compounding on what an earlier discount leaves, and every discount (line or order, percentage or fixed) is clamped to 0 so a negative value can never raise a price.

  • #99 9649259 Thanks @kilbot! - ReconcileFeedEntry (core) gains refreshOnly?: boolean: a missing product is skipped instead of tombstoned when its entry is refresh-only, so only the id reconcile's braked entries can delete (ADR-060, backlog 43). Merging in enqueue keeps refreshOnly true only when every entry queued for that id was refresh-only, so a deletable id-reconcile entry is never downgraded by a later refresh-only one. The fingerprint runner (startFingerprintReconcile, database) now enqueues its entries this way; the id runner is unchanged.

    FingerprintReconcileState (database) gains lastResultAt/lastErrorAt, stamped from an injectable now (default Date.now), so a kept lastResult next to a newer lastError can be told apart from a current one. The new isFingerprintResultCurrent(state) helper does that comparison (backlog 46).

  • #85 609ebd8 Thanks @kilbot! - Add the fingerprint reconcile (ADR-060 amendment 8): a neutral runner that compares a remote fingerprint per product against the local documents and re-delivers products whose fingerprint differs, through the collection's pull. @tallyui/core adds the FingerprintReconcileAdapter contract (fetchPages, a pure fingerprint and enqueue) and an optional reconcile.prices on TallyConnector. @tallyui/database adds startFingerprintReconcile, which runs no pass at start by default and otherwise mirrors the id reconcile: a complete, successful pass only, state$ (running, lastResult, lastError), and stop(). @tallyui/connector-medusa adds reconcile.prices, a nightly base-price backstop (MEDUSA_PRICE_RECONCILE_INTERVAL_MS) for the variant feed (ADR-060 job D1): it fingerprints each product's base prices (variant id, currency and amount, sorted, price-list prices excluded) from /admin/product-variants. Nothing is written locally; corrections arrive only through the reconcile feed's pull.

  • #97 f8b0dac Thanks @kilbot! - A fresh install downloads the catalogue once. A pull adapter can now declare pull.seedCheckpoint; on a fresh install (no stored checkpoint) combinePullAdapters reads every seed before any feed runs and starts that feed from it. The Medusa and Vendure variant feeds seed their cursor at the newest variant's updated_at, so their first pass no longer re-delivers every product the product feed has just delivered, and a variant edit made during the product feed's first pass still arrives. An install upgrading from a stored checkpoint is never seeded and keeps the variant feed's full healing pass.

  • #65 a0b7981 Thanks @kilbot! - @tallyui/core adds resolvePriceRange(variants, currency?), the lowest and highest current price across a product's variants. ProductPrice uses it to show from <lowest price> when a product's variants are priced differently, instead of just the default variant's price. New showFromPrice (default true) and fromLabel (default 'from') props control and opt out of this.

  • #61 540044c Thanks @kilbot! - Add the id reconcile (ADR-060): a periodic pass that reads every live product id and its live variant ids, so a deleted product or a deleted variant (whose parent's updatedAt does not change) reaches the local copy. @tallyui/core adds the IdReconcileAdapter contract and createReconcileFeed, which turns queued corrections into a pull-only adapter meant as the last key of combinePullAdapters. @tallyui/database adds the startIdReconcile runner. @tallyui/connector-vendure implements the Vendure side and wires it into replication.products and reconcile.ids. Nothing is written locally into the replicated collection; corrections arrive only through the collection's own pull.

  • #92 945bb83 Thanks @kilbot! - Medusa prices as Medusa charges them (ADR-060 D2b). SyncContext gains an optional pricingContext (from storeSettings()), ProductPrice an optional taxInclusive, and TallyConnector.reconcile a calculatedPrices slot. With a pricing context, every Medusa product document build fills each variant's calculated_price from the store API (null when the sales channel or region does not sell it), and the traits price from it: sale lists as a sale against the original price, override lists as the base price, null as unsellable. reconcile.calculatedPrices re-delivers products whose calculated prices changed with no timestamp bump; run it every MEDUSA_CALCULATED_PRICE_RECONCILE_INTERVAL_MS (30 minutes) with maxPages: 1000. Without a pricing context, documents and prices are unchanged.

  • #11 f90e59d Thanks @kilbot! - Add backend-neutral price and stock traits. ProductTraits gains getPrices (a price list of integer minor-unit Money entries, base or sale, per currency) and getStock (in_stock | out_of_stock | backorder | unknown plus an optional quantity). Core adds resolvePrice, moneyFromMajor, moneyToMajor and minorUnitDigits. Every connector maps its own shape into them; WooCommerce's instock/outofstock/onbackorder strings now stay inside the WooCommerce connector. The string-price and WooCommerce-style stock accessors remain and are deprecated.

  • #11 2a05ecb Thanks @kilbot! - Build the product components on the neutral traits. ProductPrice resolves the price list and formats it with Intl in the price's own currency (new currency and locale props; currencySymbol is now only the fallback for an unknown currency). ProductStockBadge reads getStock. ProductImage gains showPlaceholder, an initial tile for products without images. Core adds formatMoney, a traitContext prop on ConnectorProvider for store-level facts like the store currency, and useTraitContext. @tallyui/pos adds searchProducts, name/SKU/barcode search through traits that works the same on every backend.

  • #120 e0062ce Thanks @kilbot! - A per-store order.create capability check replaces the global discount guard (ADR-062). @tallyui/core gains ServerCapabilities, SignInResult.capabilities, SyncContext.capabilities, TallyConnector.capabilities?() and resolveCapabilities(fresh, stored). @tallyui/connector-medusa reads the store's supported order.create versions from GET /tally/v1/info: a 404 or a malformed response means an old plugin (version 1), a network failure or a 5xx is unknown and keeps the last known value, and a 401 throws. medusaSignIn returns the read capabilities, and both Medusa connectors expose capabilities(context) for a restored session. finalizeOrder in @tallyui/pos now rejects a discount only when the store's capability is below 2, so a store whose plugin has caught up finalizes a discounted order as order.create version 2.

  • #95 f1af98c Thanks @kilbot! - OrderCreateLine gains an optional taxInclusive (ADR-038 amendment 2). It is the line's own tax mode, sent only when that mode differs from the order's pricesIncludeTax. Single-mode orders produce byte-identical payloads. finalize still rejects converted lines until the Medusa plugin honours the field.

  • #157 125c85a Thanks @kilbot! - readFresh, countFresh and watchFresh move to a new, side-effect-free subpath, @tallyui/core/rxdb. Core now lists rxdb (>=16) and rxjs (>=7) as optional peer dependencies, needed only by that subpath; core's main entry stays free of both. @tallyui/pos re-exports the helpers unchanged. The id and fingerprint reconciles in @tallyui/database read the local products with readFresh instead of a cached find(), so a product the pull inserts or deletes while a pass reads them no longer leaves every later pass reading a stale list (RxDB 16.21.1 bug 4): an inserted product is now checked, and tombstoned or re-fetched, on the next pass, and a deleted one is no longer re-enqueued or counted towards the mass-delete brake.

  • #164 24b0563 Thanks @kilbot! - Register screens (WCPOS next port, ADR-032), driven by useRegisterSession: RegisterPicker, OpenRegisterCard, RegisterBar (one status pill; "Register ›"), MovementSheet (labelled "Amount" and "Reason"; a reason for every movement; same-tick taps coalesced), RegisterPanel (expected in the drawer; Undo by reversal; blind mode hides amounts) and RegisterColumn. @tallyui/core adds currencySymbol(currency, locale?).

  • #11 bc0a224 Thanks @kilbot! - Add isSellable and getVariantCount product traits to core and all four connectors.

  • #57 55ae68f Thanks @kilbot! - SignInErrorCode splits the old failed in two: failed now means no response arrived (a network error), and the new server_error means a response arrived but was unusable (a bad status, a malformed body, or a missing token). SignInError gains an optional status from a third constructor argument. Callers that switch on code should handle server_error.

    Medusa's sign-in now treats mfa_required: true and verification_required: true the same as a location body: unsupported, and no token is ever returned from a body like that. A malformed response body, any other non-OK status and a missing or non-string token are now server_error with the HTTP status.

    Vendure's sign-in now treats NATIVE_AUTH_STRATEGY_ERROR as unsupported, since native email/password auth is disabled on the server. A malformed response body, a non-OK status, GraphQL errors, a missing data.login and any other ErrorResult are now server_error with the HTTP status.

  • #64 402ec36 Thanks @kilbot! - Both connectors now store a product's variants sorted by id, since neither Medusa nor Vendure guarantees variant order across requests: @tallyui/core adds compareIds, and the Medusa and Vendure product projections (toDocument, toProductDocument) sort variants with it before the document is stored. Traits that read variants[0] (getPrices, getSku, getPrice, getStockQuantity, getBarcode and others) now see a stable variant across runs.

    Already-stored documents take the new order the next time they are delivered. Vendure's variant feed re-delivers every product on its first pass anyway, so it heals immediately.

  • #55 350967d Thanks @kilbot! - Stock reads use the reconciled overlay (ADR-060) and show how fresh it is. @tallyui/core now holds withStockOverlay and getProductStock (@tallyui/pos re-exports them), adds STOCK_LEVELS_LAST_PASS, stockOverlay and stockOverlayAsOf props on ConnectorProvider, and a useProductStock(doc) hook that returns overlay stock plus asOf, or getStock(doc) when no overlay is given. @tallyui/database: startStockReconcile also returns state$ (running, truncated, lastError, lastCompletedAt), reconcileStock() resolves with completedAt, and each successful pass stores { completedAt } in the last-pass local document of stock_levels, which createTallyDatabase now creates with local documents (apps that create the collection themselves use the new stockLevelsCollection config; without local documents a pass rejects with a clear error); a restarted runner seeds lastCompletedAt from it. @tallyui/pos adds stockOverlayAsOf$. ProductStockBadge reads stock through useProductStock and appends " · as of <time>" when an overlay is given (showAsOf={false} hides it).

  • #53 e3b8686 Thanks @kilbot! - Add a stock reconcile pass (ADR-060). @tallyui/core adds the StockReconcileAdapter contract (fetchPages and a pure overlay) and an optional reconcile.stock on TallyConnector. @tallyui/database adds the local-only stock_levels collection (STOCK_LEVELS_COLLECTION, stockLevelsSchema), which createTallyDatabase creates for connectors with reconcile.stock, and startStockReconcile, which re-reads stock every 5 minutes (and on demand through reconcileStock()) into that collection: it writes only changed rows, removes keys the backend no longer returns, writes nothing after a failed, truncated or stopped read, and never writes the replicated products. @tallyui/pos adds stockOverlay$, withStockOverlay and getProductStock, which read stock from the overlay where it has an entry and from the replicated product otherwise. The Vendure connector reconciles variant stockLevels, and the Medusa connector reconciles inventory item location levels, so stock changes that bump no product timestamp reach the POS.

  • #87 ac2a24a Thanks @kilbot! - TallyConnector gains an optional storeSettings(context, choice?) (TV4): one read-only call for the store's currency, pricesIncludeTax, taxRatesPpm and an opaque connector-specific pricingContext, so the app can feed the connector's own tax-inclusivity option and the POS TaxProvider from a single source of truth instead of two hand-matched settings. Rejects with a StoreSettingsError (choice_required with choices, or failed).

    Vendure's storeSettings reads the active channel's currency and pricesIncludeTax, and the default tax zone's enabled, non-customer-group rates keyed by tax category id (rounded to integer ppm once, at the connector's edge). default is the isDefault category's rate, or, when none is flagged, the first category Vendure's own taxCategories lists — the same fallback Vendure uses for a variant created without a category — and is 0 when that category has no rate in the zone. createVendureConnector's pricesIncludeTax option is unchanged; pass settings.pricesIncludeTax from storeSettings instead of hand-matching it to the POS.

  • #19 8df0569 Thanks @kilbot! - Adds variant traits. VariantSummary (id, title, sku, barcode, prices, stock) and the optional ProductTraits.getVariants describe every purchasable variant of a product, and findVariantByCode finds a variant by barcode or SKU for scanning. The Medusa connector implements getVariants; its product-level getPrices and getStock results are unchanged.

Patch Changes

  • #94 373e438 Thanks @kilbot! - resolvePrice keeps a price's taxInclusive flag on current and was. Each order-builder line keeps its price's own tax mode (LineItem.taxInclusive, plus priceTaxModeConverted when it differs from the store's pricesIncludeTax), so a customer pays exactly the shelf price and an inclusive price in an exclusive store is no longer taxed twice. Orders whose prices carry no flag, or one that agrees with the store, total exactly as before. The receipt shows a converted line in the order's mode, by its share of the order's once-rounded tax, so the lines still add up.

    In priced mode, the Medusa traits' deprecated getPrice and getRegularPrice return the resolved calculated price instead of the admin prices, and isSellable is false when no variant yields a price (for example a calculated_price with null amounts).

  • #11 b1b6e30 Thanks @kilbot! - Report product-level stock across all variants, showing the total quantity only when every variant has tracked, known stock. Draw the search magnifier with an attached, rounded handle and a larger ring.

@tallyui/database

Major Changes

  • #71 14871a2 Thanks @kilbot! - Breaking: getStorage() on the web no longer returns Dexie. It throws with guidance to pass RxDB Premium's SQLite-wasm storage explicitly (@tallyui/storage-sqlite/web), bundling its worker entry — the web engine ADR-061 pins as createTallyDatabase's target. Any app that relied on the Dexie default breaks; switching is a cold resync, not a data migration.

    Breaking: multiInstance: true now throws for every storage (ADR-061): the pinned web engine (opfs-sahpool) cannot share exclusive OPFS handles between tabs, so multi-instance is unsupported until job 3 removes the option along with #42's outbox code.

    Breaking: the storage deadline is now a watchdog. withWriteDeadline, STORAGE_WRITE_DEADLINE_MS, StorageWorkerTimeoutError and isStorageWorkerTimeout are removed; isStorageWorkerFailure now recognises only StorageWorkerStartError. createTallyDatabase wraps a storage marked tallyEngine: 'sqlite-sahpool' with withStorageWatchdog, following WCPOS (ADR-061), and no storage call is ever settled on a clock, because a timed-out write may still commit:

    • A write pending longer than 10s (STORAGE_WRITE_STALL_MS) is flagged as stalled, never rejected; its promise stays pending until the worker answers, and the status returns to ok once no stalled writes remain.
    • Reads are watched: two consecutive silent 30s windows (STORAGE_READ_WATCHDOG_MS), with reads pending and no storage call settling, set the status to dead, which is sticky. The recovery is to reload.
    • Creating the storage has no deadline, since the worker and wasm can be slow to download.

    getStorageHealth(db) returns the Observable<StorageHealth> ({ status: 'ok' | 'stalled' | 'dead', stalledWrites, stalledSince? }) for a database on that storage, and undefined for any other, so an app can show "saving is slow…" or "storage stopped, reload".

Minor Changes

  • #99 9649259 Thanks @kilbot! - ReconcileFeedEntry (core) gains refreshOnly?: boolean: a missing product is skipped instead of tombstoned when its entry is refresh-only, so only the id reconcile's braked entries can delete (ADR-060, backlog 43). Merging in enqueue keeps refreshOnly true only when every entry queued for that id was refresh-only, so a deletable id-reconcile entry is never downgraded by a later refresh-only one. The fingerprint runner (startFingerprintReconcile, database) now enqueues its entries this way; the id runner is unchanged.

    FingerprintReconcileState (database) gains lastResultAt/lastErrorAt, stamped from an injectable now (default Date.now), so a kept lastResult next to a newer lastError can be told apart from a current one. The new isFingerprintResultCurrent(state) helper does that comparison (backlog 46).

  • #85 609ebd8 Thanks @kilbot! - Add the fingerprint reconcile (ADR-060 amendment 8): a neutral runner that compares a remote fingerprint per product against the local documents and re-delivers products whose fingerprint differs, through the collection's pull. @tallyui/core adds the FingerprintReconcileAdapter contract (fetchPages, a pure fingerprint and enqueue) and an optional reconcile.prices on TallyConnector. @tallyui/database adds startFingerprintReconcile, which runs no pass at start by default and otherwise mirrors the id reconcile: a complete, successful pass only, state$ (running, lastResult, lastError), and stop(). @tallyui/connector-medusa adds reconcile.prices, a nightly base-price backstop (MEDUSA_PRICE_RECONCILE_INTERVAL_MS) for the variant feed (ADR-060 job D1): it fingerprints each product's base prices (variant id, currency and amount, sorted, price-list prices excluded) from /admin/product-variants. Nothing is written locally; corrections arrive only through the reconcile feed's pull.

  • #61 540044c Thanks @kilbot! - Add the id reconcile (ADR-060): a periodic pass that reads every live product id and its live variant ids, so a deleted product or a deleted variant (whose parent's updatedAt does not change) reaches the local copy. @tallyui/core adds the IdReconcileAdapter contract and createReconcileFeed, which turns queued corrections into a pull-only adapter meant as the last key of combinePullAdapters. @tallyui/database adds the startIdReconcile runner. @tallyui/connector-vendure implements the Vendure side and wires it into replication.products and reconcile.ids. Nothing is written locally into the replicated collection; corrections arrive only through the collection's own pull.

  • #69 0ef1c7e Thanks @kilbot! - ADR-061: startLiveTab (@tallyui/database) coordinates exactly one live tab per store over a Web Lock and a BroadcastChannel. A new tab asks the live tab to hand over; the live tab may delay while busy, then parks and releases the lock; a tab that gets no acknowledgement is blocked and must be closed. LiveTabScreen (@tallyui/components) renders the parked and blocked screens, with translatable label props. On platforms without Web Locks (React Native, Node), a tab is simply live at once.

  • #92 945bb83 Thanks @kilbot! - Medusa prices as Medusa charges them (ADR-060 D2b). SyncContext gains an optional pricingContext (from storeSettings()), ProductPrice an optional taxInclusive, and TallyConnector.reconcile a calculatedPrices slot. With a pricing context, every Medusa product document build fills each variant's calculated_price from the store API (null when the sales channel or region does not sell it), and the traits price from it: sale lists as a sale against the original price, override lists as the base price, null as unsellable. reconcile.calculatedPrices re-delivers products whose calculated prices changed with no timestamp bump; run it every MEDUSA_CALCULATED_PRICE_RECONCILE_INTERVAL_MS (30 minutes) with maxPages: 1000. Without a pricing context, documents and prices are unchanged.

  • #63 d9fe1e3 Thanks @kilbot! - Fix the Medusa connector's incremental pull: Medusa 2.21 honours only the operator form updated_at[$gte], and silently ignored the connector's updated_at[gte], so every pass read the whole catalogue. Add the Medusa id reconcile (ADR-060), so a deleted product or a deleted variant (whose parent's updated_at does not change) now reaches the local copy through replication.products and reconcile.ids. @tallyui/database adds a mass-deletion brake to startIdReconcile: a pass that would tombstone more than maxDeleteShare (default 20%) of local products, and more than 10 of them, queues nothing and warns instead, unless allowMassDelete is set.

  • #102 7490a3f Thanks @kilbot! - A connector schema version bump now drops and resyncs its collection (ADR-060 amendment 9). createTallyDatabase adds RxDB's migration-schema plugin and gives each connector collection above version 0 a v => null strategy per earlier version, and startReplication appends -v<version> to the replication identifier above version 0, so the pull starts from no checkpoint. Version 0 collections keep their identifier and never resync. stock_levels and pos_orders are untouched.

    The Medusa products schema is now version 1 and declares variants[].calculated_price (object or null). The first sync after upgrading resyncs the Medusa catalogue: the stored products are dropped when the database opens and download again, once, on the first sync. A collection created with medusaProductSchema outside createTallyDatabase must use connectorCollection(medusaProductSchema) from @tallyui/database, which supplies the strategies and the migration plugin; otherwise RxDB throws COL12.

  • #74 0121559 Thanks @kilbot! - Removes the unreleased multi-tab database machinery in favour of one live tab per store (ADR-061): CreateDatabaseOptions.multiInstance (createTallyDatabase always passes multiInstance: false), the tally-outbox-flush and tally-outbox-state local documents, and follower forwarding between tabs are all gone. The outbox's public API (flush, requeue, start, stop, state$) and its single-instance behaviour are unchanged. Apps enforce one live tab with startLiveTab.

    @tallyui/storage-sqlite's worker now swallows the ready promise's rejection so a pool install failure before any createStorageInstance call doesn't surface as an unhandled rejection, and its files list no longer publishes test files, matching @tallyui/database and @tallyui/pos.

  • #55 350967d Thanks @kilbot! - Stock reads use the reconciled overlay (ADR-060) and show how fresh it is. @tallyui/core now holds withStockOverlay and getProductStock (@tallyui/pos re-exports them), adds STOCK_LEVELS_LAST_PASS, stockOverlay and stockOverlayAsOf props on ConnectorProvider, and a useProductStock(doc) hook that returns overlay stock plus asOf, or getStock(doc) when no overlay is given. @tallyui/database: startStockReconcile also returns state$ (running, truncated, lastError, lastCompletedAt), reconcileStock() resolves with completedAt, and each successful pass stores { completedAt } in the last-pass local document of stock_levels, which createTallyDatabase now creates with local documents (apps that create the collection themselves use the new stockLevelsCollection config; without local documents a pass rejects with a clear error); a restarted runner seeds lastCompletedAt from it. @tallyui/pos adds stockOverlayAsOf$. ProductStockBadge reads stock through useProductStock and appends " · as of <time>" when an overlay is given (showAsOf={false} hides it).

  • #53 e3b8686 Thanks @kilbot! - Add a stock reconcile pass (ADR-060). @tallyui/core adds the StockReconcileAdapter contract (fetchPages and a pure overlay) and an optional reconcile.stock on TallyConnector. @tallyui/database adds the local-only stock_levels collection (STOCK_LEVELS_COLLECTION, stockLevelsSchema), which createTallyDatabase creates for connectors with reconcile.stock, and startStockReconcile, which re-reads stock every 5 minutes (and on demand through reconcileStock()) into that collection: it writes only changed rows, removes keys the backend no longer returns, writes nothing after a failed, truncated or stopped read, and never writes the replicated products. @tallyui/pos adds stockOverlay$, withStockOverlay and getProductStock, which read stock from the overlay where it has an entry and from the replicated product otherwise. The Vendure connector reconciles variant stockLevels, and the Medusa connector reconciles inventory item location levels, so stock changes that bump no product timestamp reach the POS.

Patch Changes

  • #66 b029839 Thanks @kilbot! - The id reconcile's mass-delete brake now also trips when every local product would be tombstoned, whatever the count, unless allowMassDelete is set. Previously the brake applied only above MASS_DELETE_MINIMUM (10) would-be tombstones, so a wrong channel token that made a shop of 10 or fewer products look empty could tombstone its whole catalogue.

  • #11 184901f Thanks @kilbot! - createTallyDatabase now wraps its storage in the AJV schema validator in development. RxDB dev-mode refuses to create a database without one (error DVM1), so every app using the default options failed at startup.

  • #14 c9b0bb8 Thanks @kilbot! - createTallyDatabase now works in production builds. It passed ignoreDuplicate: true unconditionally, which RxDB rejects with error DB9 whenever dev mode is off, so no production app could open its database. The option is now only set in dev mode, where hot reload still re-creates a database with the same name.

  • #78 9168278 Thanks @kilbot! - The live tab's busy-defer now caps by wall-clock time elapsed since the hand-over ack, not by counting sleep(100) calls. The live tab is in the background exactly when a new tab asks it to hand over, and browsers throttle background timers to about 1 s or more, so a sleep count could stretch the intended maxDeferMs (10 s by default) far beyond that, leaving the new tab stuck in acquiring.

  • #157 125c85a Thanks @kilbot! - readFresh, countFresh and watchFresh move to a new, side-effect-free subpath, @tallyui/core/rxdb. Core now lists rxdb (>=16) and rxjs (>=7) as optional peer dependencies, needed only by that subpath; core's main entry stays free of both. @tallyui/pos re-exports the helpers unchanged. The id and fingerprint reconciles in @tallyui/database read the local products with readFresh instead of a cached find(), so a product the pull inserts or deletes while a pass reads them no longer leaves every later pass reading a stale list (RxDB 16.21.1 bug 4): an inserted product is now checked, and tombstoned or re-fetched, on the next pass, and a deleted one is no longer re-enqueued or counted towards the mass-delete brake.

  • #108 caa1fd2 Thanks @kilbot! - A reconcile trigger during a pass now runs one follow-up pass instead of being dropped.

@tallyui/primitives

Minor Changes

Patch Changes

@tallyui/components

Major Changes

  • #23 53af670 Thanks @kilbot! - Breaking: the cart and checkout components are presentational and take Money. CartLine takes name, quantity, unitPrice and lineTotal. CartTotal takes subtotal, taxLines, discount and total. CashTendered and ChangeDisplay take Money amounts. All of them format with formatMoney, so there is no getPrice, float arithmetic or hard-coded '$'. CartPanel is generic, and CartLineItem is removed. Totals come from @tallyui/pos; the components no longer compute tax. The cash input keeps the text as typed and emits integer minor units.

    @tallyui/core adds moneyFromDecimalString, which parses typed decimal text into Money using integer arithmetic.

Minor Changes

  • #136 0b1237f Thanks @kilbot! - CartPanel's footer (totals, pay button) now stays pinned to the bottom of the panel at every height, on web and native, instead of being pushed off screen by a tall list of lines. CartPanel also gains an afterItems slot, rendered inside the scrolling region after the last line, for content like discount chips.

  • #148 a80e053 Thanks @kilbot! - Catalogue takes an optional minCodeLength prop (a till's barcode-scanner setting). When set, Enter on a search query shorter than minCodeLength (after trimming) no longer does a barcode/SKU lookup — it leaves the typed text as a plain search instead of selecting an entry. Unset, behaviour is unchanged. A scanner's timing threshold stays the app's own concern, in its unfocused wedge listener.

  • #175 a9b77fe Thanks @kilbot! - One close in flight per register. useRegisterSession's closeSession joins a close already running for the same register, in any hook instance, and returns its closure (the joining call's counted, approvedBy and approvedByName are ignored), so a tap during a close no longer starts a second, overlapping one. The hook returns a new closing flag, true while that close is in flight. RegisterColumn keeps the count slot up while closing instead of flashing the Finish-closing card, whose button now has nativeID="register-column-finish-close-button". describeRegisterBarPill takes closing and returns the new 'Close not finished' pill for a closed session that isn't closing, right after 'Choose a register' and ahead of 'Offline'; RegisterBar passes register.closing.

  • #174 160252c Thanks @kilbot! - ClosureSheet shows who approved the close (Approved by {name}, falling back to the approver id without a name) under the figures, blind or not — it's provenance, not a counted figure. RegisterColumn offers "Finish closing" when useRegisterSession's session is closed but its closure row was never written (an interrupted close), resuming the close (the store keeps the count persisted on the session) instead of falling through to the cart, where openSession would otherwise refuse with RegisterCloseIncompleteError. buildClosureDocument's return type now carries closure.unsynced_count: number and each movement's id/reason as string, without a cast; no runtime output changed.

  • #107 f132a68 Thanks @kilbot! - ConnectorStatus gains unsoldCount, unsoldStale and formatUnsold props, showing how many products the sales channel doesn't sell (the calculated-price runner's unreported) below lastSync, using the warning token when current and muted-foreground when stale.

  • #149 1e2ee56 Thanks @kilbot! - A failed save can now end in Continue once its order is confirmed stored. useOrderOutbox gains isStored(order), and record now treats an order stored with the same id and money-bearing content (sameSale, new) as stored whatever its commandId, so a Retry after a requeue no longer fails forever; other content throws the new OrderContentMismatchError. useSale takes an optional isStored and exposes canContinue and continueSale(); Tender renders Continue when canContinue is true. newSale() is now refused while a failed or running save's order isn't confirmed stored; a refusal during a running save asks isStored again, so a hung save whose order is stored can still Continue. A confirmed order is never handed to onSaleCompleted again (#147's background re-hand is gone). saleLogger and outboxLogger are now exported.

  • #132 7c69fce Thanks @kilbot! - order.display gains lines and orderDiscountMinor (ADR-063). Each line shows its amount before any discount, with its own discounts as sub-rows, all in the display mode. The order discounts appear as one row, not allocated to the lines. Every discount row is its own-mode amount converted on its own, so it's exact. display.subtotalMinor is now derived from the total and the discount rows, so Σ lines === subtotalMinor and Σ sub-rows + orderDiscountMinor === discountMinor hold exactly. Single-mode carts show the same figures as before; mixed-mode carts can shift by about a cent, carried by the last converted line's amount.

    ReceiptLineItem gains displayAmountMinor and displayDiscounts, and ReceiptData gains orderDiscountMinor. Print these above the subtotal. lineTotalMinor is unchanged: it's after every discount and is kept for existing readers.

    CartTotal now orders its rows Subtotal / Discount / Tax / Total, matching the receipt, and takes an optional taxInclusive, which labels the tax rows "incl." instead of adding them.

  • #65 a0b7981 Thanks @kilbot! - @tallyui/core adds resolvePriceRange(variants, currency?), the lowest and highest current price across a product's variants. ProductPrice uses it to show from <lowest price> when a product's variants are priced differently, instead of just the default variant's price. New showFromPrice (default true) and fromLabel (default 'from') props control and opt out of this.

  • #141 6a4e80d Thanks @kilbot! - Lifted medusapos's product catalogue, receipt, print-style hook and sync status into @tallyui/components (Catalogue, Receipt, injectPrintStyle, SyncStatus), so every platform POS gets the same screens (ADR-052, TV6b). Catalogue takes an optional hour12?: boolean (undefined keeps the locale default) instead of reading expo-localization. Receipt takes store: { name: string; address?: string } instead of a Medusa-shaped settings type, an optional topInset?: number (default 0) instead of an app-local strip-height context, an optional formatDate?: (iso: string) => string defaulting to an Intl.DateTimeFormat formatter, and an optional taxLabel?: (ratePpm: number) => string with the same default as Cart's (TV6a), keeping the incl. prefix rule. searchProducts, catalogueEntries, findEntryByCode and variantPriceLabel join buildReceiptData on the pure-function allow-list components may import from @tallyui/pos (ADR-064).

  • #142 60a2218 Thanks @kilbot! - Lifted medusapos's neutral outbox core so every platform POS records and sends sales the same way (ADR-052, TV7). @tallyui/pos gains getDeviceId(storage, key), which keeps a UUIDv7 device id in web storage under the given key and falls back to one id per process; needsAttention(orders), which picks rejected and applied-with-warnings orders, newest first; and useOrderOutbox({ storeKey, open, transport, deviceId, onBusy?, onOpenError? }), which opens the order store for storeKey, runs its outbox and returns { orders, state, recent, record, flush, requeue }. @tallyui/components gains OrdersList, the "Needs attention" and "Recent" orders with a Retry button, taking orders, onRetry, an optional formatDate (default Intl.DateTimeFormat) and an optional footer. needsAttention joins the pure-function allow-list components may import from @tallyui/pos (ADR-064).

  • #139 3dd11f6 Thanks @kilbot! - Lifted medusapos's cart, phone cart bar, discount form and tender screen into @tallyui/components (Cart, CartBar, Tender, DiscountForm, DiscountChips, parseDiscount, discountLabel), so every platform POS gets the same sale-column UI (ADR-052, TV6a). Cart takes an optional taxLabel?: (ratePpm: number) => string (default `Tax ${ratePpm / 10000}%`), since VAT isn't universal. @tallyui/components gains a runtime dependency on @tallyui/pos, for types and the pure buildReceiptData only — components still render from props alone.

  • #69 0ef1c7e Thanks @kilbot! - ADR-061: startLiveTab (@tallyui/database) coordinates exactly one live tab per store over a Web Lock and a BroadcastChannel. A new tab asks the live tab to hand over; the live tab may delay while busy, then parks and releases the lock; a tab that gets no acknowledgement is blocked and must be closed. LiveTabScreen (@tallyui/components) renders the parked and blocked screens, with translatable label props. On platforms without Web Locks (React Native, Node), a tab is simply live at once.

  • #11 2a05ecb Thanks @kilbot! - Build the product components on the neutral traits. ProductPrice resolves the price list and formats it with Intl in the price's own currency (new currency and locale props; currencySymbol is now only the fallback for an unknown currency). ProductStockBadge reads getStock. ProductImage gains showPlaceholder, an initial tile for products without images. Core adds formatMoney, a traitContext prop on ConnectorProvider for store-level facts like the store currency, and useTraitContext. @tallyui/pos adds searchProducts, name/SKU/barcode search through traits that works the same on every backend.

  • #144 e692c40 Thanks @kilbot! - pos_orders goes to schema version 2 (ADR-032, ADR-065). It adds three optional fields: lateSessionId, and ADR-065's display and taxByRate, which nothing writes yet. Apps must adopt this release's addPosOrderCollection, which migrates pos_orders to version 2 from version 0 or 1 without dropping an order.

    A sale whose session refuses the stamp in useSale().complete() (the session closed or went missing) is no longer stopped, because the money has been taken. It goes on to onSaleCompleted and the receipt with lateSessionId set and no sessionId, so no closure counts it, and a late-sale register fact is recorded. needsAttention now also selects any order with lateSessionId, and OrdersList explains it: "Taken after the register closed. It is not in that register's closure."

  • #76 e225222 Thanks @kilbot! - ProductPrice gains a formatFrom prop, (price: string) => string, for languages whose word order puts the "from" label after the price (formatFrom={(p) => \${p} ab\}renders€10.00 ab); fromLabelstays as a deprecated alias. Visible change: every price — the regular price, the "from" range and the sale price — now uses the theme'stext-price(ortext-sale) token instead of text-foreground. Apps with screenshot tests covering ProductPrice will need to re-shoot them.

  • #167 62eef0f Thanks @kilbot! - Register-selection screen nits (ADR-032 amendment 1, medusapos adopting 451a0ca): RegisterPicker's rows now size to their content, with a minimum height, instead of clipping the "Not opened" second line at a fixed h-11; RegisterPicker and OpenRegisterCard no longer hard-code flex-1, taking their existing className from the caller instead (TallyUI's own RegisterColumn still passes flex-1 where it mounts them); RegisterPanel's sales count now pluralises correctly ("1 sale this session", not "1 sales"); and OpenRegisterCard's amount label names the currency, matching MovementSheet's "Amount (€)" ("Cash in the drawer to start (€)").

    Two new optional props for apps that need to put register controls elsewhere on the screen: RegisterBar takes an onPressPill?: () => void that turns its status pill into a button (opening the gate/picker or the panel), and Catalogue takes a statusAccessory?: ReactNode rendered at the end of its status line, alongside the status text, so a register control can sit there at phone width.

  • #164 24b0563 Thanks @kilbot! - Register screens (WCPOS next port, ADR-032), driven by useRegisterSession: RegisterPicker, OpenRegisterCard, RegisterBar (one status pill; "Register ›"), MovementSheet (labelled "Amount" and "Reason"; a reason for every movement; same-tick taps coalesced), RegisterPanel (expected in the drawer; Undo by reversal; blind mode hides amounts) and RegisterColumn. @tallyui/core adds currencySymbol(currency, locale?).

  • #166 c65da52 Thanks @kilbot! - RegisterCount and ClosureSheet (WCPOS next port, ADR-032 amendment 1): denomination tiles counted in minor units (tap adds one, a 400ms hold adds ten), typing a cash amount clears the tiles, a live variance line hidden while blind, and Close gated by an optional approve prop above varianceThreshold (refused with an exact message without one). ClosureSheet shows the local closure number and, unless blind, the counted figures and variance per tender.

  • #55 350967d Thanks @kilbot! - Stock reads use the reconciled overlay (ADR-060) and show how fresh it is. @tallyui/core now holds withStockOverlay and getProductStock (@tallyui/pos re-exports them), adds STOCK_LEVELS_LAST_PASS, stockOverlay and stockOverlayAsOf props on ConnectorProvider, and a useProductStock(doc) hook that returns overlay stock plus asOf, or getStock(doc) when no overlay is given. @tallyui/database: startStockReconcile also returns state$ (running, truncated, lastError, lastCompletedAt), reconcileStock() resolves with completedAt, and each successful pass stores { completedAt } in the last-pass local document of stock_levels, which createTallyDatabase now creates with local documents (apps that create the collection themselves use the new stockLevelsCollection config; without local documents a pass rejects with a clear error); a restarted runner seeds lastCompletedAt from it. @tallyui/pos adds stockOverlayAsOf$. ProductStockBadge reads stock through useProductStock and appends " · as of <time>" when an overlay is given (showAsOf={false} hides it).

  • #96 93658cd Thanks @kilbot! - StoreSettingsChoiceScreen: the picker the app shows when storeSettings rejects with choice_required (TV4), for a store with several regions, countries or sales channels — medusa-dev's one region with 7 countries always hits this. Renders a radio-row section per choice offered, pre-selects a single-option section or a matching initial value, and calls onSubmit with the picked fields once every shown section has a selection.

  • #12 fe65b33 Thanks @kilbot! - Add createSvgIcon and SearchIcon, and replace the SearchInput text glyph with an SVG magnifier. react-native-svg >=15 is now a peer dependency.

  • #104 bc46d99 Thanks @kilbot! - ProductGrid hides products this channel doesn't sell (traits.isSellable false, for example a Medusa product outside the sales channel) unless showUnsellable is set, and ProductCard shows such a product in a muted "Not sold here" state instead of its price. OrderBuilder.addProduct now refuses an unsellable product before looking up its price, instead of throwing the unrelated "No price in …" error.

Patch Changes

  • #168 670d3b8 Thanks @kilbot! - The register approval gate is enforced in useRegisterSession's closeSession, not only in RegisterCount: over varianceThreshold, a close without approvedBy throws the new RegisterApprovalRequiredError before any write, blind mode included. closeSession takes approvedBy and approvedByName, and they reach the Z (breakdowns.approved_by, approved_by_name); the store's closeSession takes approvedBy. useRegisterSession's register option accepts null while its host opens. closeNeedsApproval is the shared "over threshold" rule. RegisterCount passes approve()'s approvedBy and approvedByName to closeSession, and shows the hook's refusal with the same copy.

  • #137 314d1fc Thanks @kilbot! - CartPanel rows now default to keying by the item's id (string or number), falling back to index only for items without one, instead of always keying by index — so removing a line above another with an open inline form (e.g. a per-line discount editor) no longer remounts and loses that form's state. Pass keyExtractor to override.

  • #169 6c9a176 Thanks @kilbot! - Catalogue's status text now truncates to a single line (with an ellipsis) when statusAccessory is set, instead of wrapping to three lines at phone width and pushing the accessory (e.g. a register pill) out of place. Without statusAccessory, the status text still wraps as before.

  • #43 f1e2a02 Thanks @kilbot! - Status badges use foreground text on their tint (the dot carries the colour), since coloured text on a 15% tint of itself fails WCAG AA; the refunded order badge uses the destructive token instead of the undefined danger. Search, cart-note and customer inputs take their placeholder colour from the muted-foreground token.

  • #37 12b7723 Thanks @kilbot! - Order cards, receipt preview, register summary, settings groups, connector status, variant picker, cash-tendered input, cart note and customer form used the undefined bg-surface class and rendered transparent; they now use bg-card.

  • #88 5053527 Thanks @kilbot! - no longer publishes test files

  • #11 b1b6e30 Thanks @kilbot! - Report product-level stock across all variants, showing the total quantity only when every variant has tracked, known stock. Draw the search magnifier with an attached, rounded handle and a larger ring.

  • #36 d1bc892 Thanks @kilbot! - Light theme contrast: primary darkened to #5b5ef0 (white text 4.88:1), muted-foreground to #656c79 and input to #848a94 (control borders ≥ 3:1). Product cards and quick-tender buttons get borders, and components no longer use the undefined bg-surface-alt or text-muted text classes, so labels such as "Change Due" are legible.

@tallyui/storage-sqlite

Minor Changes

  • #33 a0d2b90 Thanks @kilbot! - getRxStorageSQLite(database) now returns RxDB Premium's SQLite storage instead of TallyUI's own engine, so writes are transactional and queries run on real SQLite. The call is unchanged: pass a synchronous SQLite handle such as expo-sqlite's openDatabaseSync(...). rxdb-premium@16.21.1 is now a peer dependency that your app installs under its own RxDB Premium licence. Use one handle per RxDB database; the storage never closes the handle, your app does.

  • #70 c15c04d Thanks @kilbot! - Adds the web storage (ADR-061): getRxStorageSQLiteWasm from @tallyui/storage-sqlite/web runs RxDB Premium's SQLite storage on @sqlite.org/sqlite-wasm's opfs-sahpool VFS, in one dedicated worker owned by the live tab, reached through premium's getRxStorageWorker in mode 'one'. The app supplies the worker input, for example () => new Worker(new URL('@tallyui/storage-sqlite/web-worker', import.meta.url), { type: 'module' }). @sqlite.org/sqlite-wasm is now a peer dependency that your app installs. The returned storage carries tallyEngine: 'sqlite-sahpool' so callers can recognise it. The root export is unchanged and does not pull in wasm.

  • #71 14871a2 Thanks @kilbot! - Added tallyui-build-sqlite-worker, a bin script that prebuilds the web-worker entry (with esbuild) and copies sqlite3.wasm next to it, for Metro/Expo web apps that can't bundle a module worker at runtime.

  • #100 94e1f09 Thanks @kilbot! - getRxStorageSQLiteWasm returns a storage with terminate(), which stops its worker and clears the cached channel, so a fresh open after the live-tab park no longer hangs.

Patch Changes

  • #74 0121559 Thanks @kilbot! - Removes the unreleased multi-tab database machinery in favour of one live tab per store (ADR-061): CreateDatabaseOptions.multiInstance (createTallyDatabase always passes multiInstance: false), the tally-outbox-flush and tally-outbox-state local documents, and follower forwarding between tabs are all gone. The outbox's public API (flush, requeue, start, stop, state$) and its single-instance behaviour are unchanged. Apps enforce one live tab with startLiveTab.

    @tallyui/storage-sqlite's worker now swallows the ready promise's rejection so a pool install failure before any createStorageInstance call doesn't surface as an unhandled rejection, and its files list no longer publishes test files, matching @tallyui/database and @tallyui/pos.

  • #72 91d9169 Thanks @kilbot! - The worker listens before start-up finishes, fixing a hang on every cold start in real browsers.

  • #91 fec2ee7 Thanks @kilbot! - The worker build bin no longer needs a built dist.

@tallyui/pos

Major Changes

  • #24 e7b2fa5 Thanks @kilbot! - Breaking: removes the floating-point money and rate paths.
    • calculateTax, extractTax, addTax, TaxResult and formatCurrency are deleted. Use the exact tax API (computeOrderTax, taxMicros) and formatMoney.
    • TaxContext.getTaxRate(), which returned a fraction, is replaced by getTaxRatePpm(), which returns integer parts per million.
    • TaxProvider takes ratesPpm and validates it.
    • useCurrencyFormatter() now formats Money, and useCurrencyCode() is added.

Minor Changes

  • #168 670d3b8 Thanks @kilbot! - The register approval gate is enforced in useRegisterSession's closeSession, not only in RegisterCount: over varianceThreshold, a close without approvedBy throws the new RegisterApprovalRequiredError before any write, blind mode included. closeSession takes approvedBy and approvedByName, and they reach the Z (breakdowns.approved_by, approved_by_name); the store's closeSession takes approvedBy. useRegisterSession's register option accepts null while its host opens. closeNeedsApproval is the shared "over threshold" rule. RegisterCount passes approve()'s approvedBy and approvedByName to closeSession, and shows the hook's refusal with the same copy.

  • #175 a9b77fe Thanks @kilbot! - One close in flight per register. useRegisterSession's closeSession joins a close already running for the same register, in any hook instance, and returns its closure (the joining call's counted, approvedBy and approvedByName are ignored), so a tap during a close no longer starts a second, overlapping one. The hook returns a new closing flag, true while that close is in flight. RegisterColumn keeps the count slot up while closing instead of flashing the Finish-closing card, whose button now has nativeID="register-column-finish-close-button". describeRegisterBarPill takes closing and returns the new 'Close not finished' pill for a closed session that isn't closing, right after 'Choose a register' and ahead of 'Offline'; RegisterBar passes register.closing.

  • #149 1e2ee56 Thanks @kilbot! - A failed save can now end in Continue once its order is confirmed stored. useOrderOutbox gains isStored(order), and record now treats an order stored with the same id and money-bearing content (sameSale, new) as stored whatever its commandId, so a Retry after a requeue no longer fails forever; other content throws the new OrderContentMismatchError. useSale takes an optional isStored and exposes canContinue and continueSale(); Tender renders Continue when canContinue is true. newSale() is now refused while a failed or running save's order isn't confirmed stored; a refusal during a running save asks isStored again, so a hung save whose order is stored can still Continue. A confirmed order is never handed to onSaleCompleted again (#147's background re-hand is gone). saleLogger and outboxLogger are now exported.

  • #115 4df9be4 Thanks @kilbot! - Discounts are pre-tax (ADR-062). An order discount is allocated across the lines in proportion to their own-mode amounts (the new allocateOrderDiscount, largest-remainder rounding), each line carries its share in orderDiscountMinor and is taxed after it, so an order discount now lowers the tax instead of coming off the total after tax. A discounted order.create is version 2, with discountMinor on each discounted line and on the payload; a discount-free payload stays version 1, byte-identical. finalize still rejects discounts until the plugins honour version 2. Receipt lines show their discountMinor. Stacked percentage order discounts are additive, each computed on the pre-order-discount base rather than compounding on what an earlier discount leaves, and every discount (line or order, percentage or fixed) is clamped to 0 so a negative value can never raise a price.

  • #132 7c69fce Thanks @kilbot! - order.display gains lines and orderDiscountMinor (ADR-063). Each line shows its amount before any discount, with its own discounts as sub-rows, all in the display mode. The order discounts appear as one row, not allocated to the lines. Every discount row is its own-mode amount converted on its own, so it's exact. display.subtotalMinor is now derived from the total and the discount rows, so Σ lines === subtotalMinor and Σ sub-rows + orderDiscountMinor === discountMinor hold exactly. Single-mode carts show the same figures as before; mixed-mode carts can shift by about a cent, carried by the last converted line's amount.

    ReceiptLineItem gains displayAmountMinor and displayDiscounts, and ReceiptData gains orderDiscountMinor. Print these above the subtotal. lineTotalMinor is unchanged: it's after every discount and is kept for existing readers.

    CartTotal now orders its rows Subtotal / Discount / Tax / Total, matching the receipt, and takes an optional taxInclusive, which labels the tax rows "incl." instead of adding them.

  • #127 2a0ca7f Thanks @kilbot! - Order gains display: DisplayTotals (ADR-063): the cart's subtotal before discounts, the discount, the tax and the total in the store's display mode, which add up on screen even in a mixed-mode cart. The settlement figures (subtotalMinor, discountMinor, taxMinor, totalMinor) and the order.create payload are unchanged; display is never sent to the server; a parked draft may carry it, but it is recomputed on resume.

  • #163 714b4bd Thanks @kilbot! - useSale's hung-save check guard is now scoped per completion instead of a shared boolean: a sale whose isStored never settles can no longer block a later sale's own hung-save poll from ever confirming and offering Continue (medusapos's #85 review).

    useOrderOutbox's isStored now logs "A stored order has this id with different content" at most once per order id for the hook's lifetime, instead of on every 5 s poll of a hung save.

    useOrderOutbox also exposes savesInFlight: number, the count of record() calls not yet settled, so an app can hold sign-out while a save is in flight.

  • #161 f4a4d74 Thanks @kilbot! - A hung save — one whose order is built and whose save neither resolves nor throws — now re-asks isStored by itself every 5 s while it stays unconfirmed, so useSale sets canContinue and Tender can offer Continue with no user action. This covers an app whose tender has no New sale control while saving (medusapos), which never triggered the existing refused-newSale() check. The poll clears when the save settles, on confirmation, on newSale()/continueSale() and on unmount; at most one runs at a time. SALE_SAVING is now exported from @tallyui/pos.

  • #142 60a2218 Thanks @kilbot! - Lifted medusapos's neutral outbox core so every platform POS records and sends sales the same way (ADR-052, TV7). @tallyui/pos gains getDeviceId(storage, key), which keeps a UUIDv7 device id in web storage under the given key and falls back to one id per process; needsAttention(orders), which picks rejected and applied-with-warnings orders, newest first; and useOrderOutbox({ storeKey, open, transport, deviceId, onBusy?, onOpenError? }), which opens the order store for storeKey, runs its outbox and returns { orders, state, recent, record, flush, requeue }. @tallyui/components gains OrdersList, the "Needs attention" and "Recent" orders with a Retry button, taking orders, onRetry, an optional formatDate (default Intl.DateTimeFormat) and an optional footer. needsAttention joins the pure-function allow-list components may import from @tallyui/pos (ADR-064).

  • #138 f09dbbc Thanks @kilbot! - @tallyui/pos gains useSale, addEntryToCart/CartError and catalogueEntries/findEntryByCode/variantPriceLabel (ADR-052, TV5), lifted from medusapos/app a1b981d's use-sale, lib/cart and lib/catalogue with the same behaviour. useSale takes an optional session, and stamps a completed sale with stampSession before onSaleCompleted; without it, behaviour is unchanged.

  • #151 c664d4b Thanks @kilbot! - New export watchFresh(collection, query): a live list on readFresh instead of a cached find().$, so a write during a query's storage read (RxDB 16.21.1 bug 4) still shows up once its change event arrives. useRegisterSession and useOrderOutbox's recent list now use it.

  • #11 2a05ecb Thanks @kilbot! - Build the product components on the neutral traits. ProductPrice resolves the price list and formats it with Intl in the price's own currency (new currency and locale props; currencySymbol is now only the fallback for an unknown currency). ProductStockBadge reads getStock. ProductImage gains showPlaceholder, an initial tile for products without images. Core adds formatMoney, a traitContext prop on ConnectorProvider for store-level facts like the store currency, and useTraitContext. @tallyui/pos adds searchProducts, name/SKU/barcode search through traits that works the same on every backend.

  • #120 e0062ce Thanks @kilbot! - A per-store order.create capability check replaces the global discount guard (ADR-062). @tallyui/core gains ServerCapabilities, SignInResult.capabilities, SyncContext.capabilities, TallyConnector.capabilities?() and resolveCapabilities(fresh, stored). @tallyui/connector-medusa reads the store's supported order.create versions from GET /tally/v1/info: a 404 or a malformed response means an old plugin (version 1), a network failure or a 5xx is unknown and keeps the last known value, and a 401 throws. medusaSignIn returns the read capabilities, and both Medusa connectors expose capabilities(context) for a restored session. finalizeOrder in @tallyui/pos now rejects a discount only when the store's capability is below 2, so a store whose plugin has caught up finalizes a discounted order as order.create version 2.

  • #17 47b9b4e Thanks @kilbot! - Adds an exact, integer-only tax API: ratePpmFromPercent, taxMicros, roundMicrosToMinor and computeOrderTax. Line tax is kept exactly in micro-minor-units (as a bigint) and rounded once, half away from zero, at the order total. This matches Medusa, which keeps line tax unrounded and rounds only at payment. The existing float tax functions are unchanged for now.

  • #21 64cb5e0 Thanks @kilbot! - Adds the PosOrder document, the neutral record of a completed sale. finalizeOrder turns a fully paid builder Order into a pending PosOrder: UUIDv7 ids, cash change allocated so that payments reconcile to the total exactly, and a refusal of discounted orders until the command contract supports discounts. toOrderCreateEnvelope maps it to the TallyUI Sync Protocol order.create command, posOrderSchema stores it in a pos_orders RxDB collection, and uuidv7 generates RFC 9562 ids.

  • #20 98ea990 Thanks @kilbot! - Breaking (pre-1.0): the order model now uses integer minor units throughout. Order, LineItem, Payment, discounts and ReceiptData money fields carry a Minor suffix (totalMinor, unitPriceMinor, amountMinor, ...). Tax is exact and rounded once per order (the tax/exact API). Lines carry stacked taxLines. The receipt's per-rate tax lines always sum to the charged tax (largest remainder). addProduct prices from getPrices and resolvePrice rather than the deprecated getPrice. The new addLine adds a specific variant at a given price with optional tax rates. Parked orders resume through addLine, with no synthetic traits.

  • #22 55d52fa Thanks @kilbot! - Adds the order outbox. createOrderOutbox sends pending PosOrder documents from the pos_orders collection through the TallyUI Sync Protocol, in batches of up to 10. It applies each result (applied, duplicate or rejected) with guarded patches and never drops a sale: transport failures and responses that make no progress retry forever, with jittered exponential backoff. createHttpCommandTransport posts to /tally/v1/commands with the protocol header, and classifies every non-200 or malformed reply as retryable.

  • #131 fbcaf59 Thanks @kilbot! - addPosOrderCollection(db) is now the way to open pos_orders. It resolves only once every version-0 order has migrated, and on DM4 it rejects after the migration has stopped, keeping every order. RxDB's own open path could report the collection ready before orders written after a rollback had moved, and after a DM4 it rejected at once while the migration carried on, so on SQLite a close could interrupt it on every open.

  • #144 e692c40 Thanks @kilbot! - pos_orders goes to schema version 2 (ADR-032, ADR-065). It adds three optional fields: lateSessionId, and ADR-065's display and taxByRate, which nothing writes yet. Apps must adopt this release's addPosOrderCollection, which migrates pos_orders to version 2 from version 0 or 1 without dropping an order.

    A sale whose session refuses the stamp in useSale().complete() (the session closed or went missing) is no longer stopped, because the money has been taken. It goes on to onSaleCompleted and the receipt with lateSessionId set and no sessionId, so no closure counts it, and a late-sale register fact is recorded. needsAttention now also selects any order with lateSessionId, and OrdersList explains it: "Taken after the register closed. It is not in that register's closure."

  • #39 f2f386c Thanks @kilbot! - The command outbox no longer retries every HTTP error forever. After 3 consecutive 401s it pauses and sets authRequired in its state so the app can ask the cashier to sign in, then resumes on the next flush(). A permanent refusal of a whole batch (400, 403, 413, 415, 422) changes no order: sending pauses with refused: { status, reason } in the state until the next flush(). requeue(orderIds?) moves rejected orders back to pending with a new commandId, except those rejected with idempotency_mismatch, which are left for reconciliation. TransportOutcome gains unauthorized and refused kinds.

  • #94 373e438 Thanks @kilbot! - resolvePrice keeps a price's taxInclusive flag on current and was. Each order-builder line keeps its price's own tax mode (LineItem.taxInclusive, plus priceTaxModeConverted when it differs from the store's pricesIncludeTax), so a customer pays exactly the shelf price and an inclusive price in an exclusive store is no longer taxed twice. Orders whose prices carry no flag, or one that agrees with the store, total exactly as before. The receipt shows a converted line in the order's mode, by its share of the order's once-rounded tax, so the lines still add up.

    In priced mode, the Medusa traits' deprecated getPrice and getRegularPrice return the resolved calculated price instead of the admin prices, and isSellable is false when no variant yields a price (for example a calculated_price with null amounts).

  • #130 516850f Thanks @kilbot! - ReceiptData.totals now comes from order.display (ADR-063), not the settlement fields: subtotalMinor is before discounts and discountMinor is every discount, both in the store's display mode, and a new taxInclusive flag says whether the tax is added or already included. taxMinor and totalMinor are unchanged in value. This is a behaviour change for anything that reads ReceiptData.totals: the receipt's invariant is now Σ lineTotalMinor === totals.subtotalMinor − totals.discountMinor, plus + totals.taxMinor === totals.totalMinor when exclusive, or === totals.totalMinor when inclusive. Receipt lines and each line's own discount row are unchanged, still in the line's own mode.

  • #125 0988fc3 Thanks @kilbot! - Adds the register closure's pure report maths (ADR-032, registers job b1), ported from WCPOS next at 3b5331b5c: settled figures after corrections (deriveSettled, Correction, RecordedFigures), a CSV export of the shown closures (exportCsv), the offline document label keys (labelKeys), and the closures list's scope clamp and row selector (clampClosureScope, selectClosureRows, ClosureScope). Money is a2's integer minor units throughout, and clampClosureScope's history window is a historyDays parameter (default 92, WCPOS's HISTORY_DAYS) instead of a @wcpos/sync-core import.

  • #134 1d13699 Thanks @kilbot! - Add the register's closure and X-report documents (buildClosureDocument, buildXReportDocument, formatClosureDate, ClosureContext) and register facts (RegisterFact, recordRegisterFact), ported from WCPOS next (ADR-032). The documents use WCPOS's own receipt-template envelope shape, so its shipped templates and renderer can be copied in later; a pinned key-tree snapshot against WCPOS's closure fixture guards that shape until then. Facts log through TallyUI's own logger (@tallyui/pos's logging module) instead of @wcpos/utils/logger. minorToDecimal moves out of exportCsv into a shared register helper so both reuse it.

  • #121 9441c26 Thanks @kilbot! - Adds @tallyui/pos's register maths (packages/pos/src/register), ported from WCPOS next at 3b5331b5c (ADR-032, registers job a1): typed-movement validation against the server's paid-in/paid-out/no-sale grammar (movementFieldError, normalizeAmount, isServerDecimal), the register count's variance, threshold, denomination and amount maths (countVariance, overThreshold, denominationTotal, varianceText, validAmount, parseMinor, denominations), and deriveExpected for the float, captured session payments and non-voided paid-in/paid-out cash movements. Refund attribution is deferred until TallyUI has a refund model, so deriveExpected does not yet net refunds against the drawer. All money is TallyUI's integer-minor-units convention, with a required exponent parameter wherever a cashier's typed text becomes minor units (0 for JPY, 2 for GBP, 3 for KWD).

  • #126 45e0b12 Thanks @kilbot! - stampSession(order, sessionId, sessions) is the only way to set a PosOrder's sessionId: it verifies the session is still open or counting before stamping, so a caller that skips requireOpenSession can no longer leave a sale off every Z with an unchecked, closed session id. finalizeOrder no longer takes a sessionId option; stamp its result with stampSession instead.

    recordMovement now takes the closures collection: recordMovement(sessions, movements, closures, input). After inserting a movement, it re-reads the session. If the session closed in the gap, the movement is removed and RegisterSessionClosedError is thrown only when the session's closure row is already frozen without it. If that closure row lists it, the movement is returned as counted. With no closure row yet, the movement is kept and the new, exported RegisterMovementStrandedError is thrown: it carries the movement's id and session_id, and its message can be shown to the cashier as it is. The caller must not record that movement again; registers job c's server resolves stranded movements. A failed remove() throws RegisterMovementStrandedError too, and a failed re-read returns the movement as recorded.

  • #123 a58fcca Thanks @kilbot! - Adds register sessions (ADR-032, registers job a2), ported from WCPOS next at 3b5331b5c: three local-only collections (registerSessionSchema with registerSessionCollection, cashMovementSchema, closureSchema), the session write path (openSession, startCounting, backToSelling, closeSession, recordMovement, voidMovement, requireOpenSession, writeClosure), and the register document for the till's identity, store binding and counters (ensureRegister, bindRegister, nextSaleCounter, mintClosureNumber, advancePerpetual). A PosOrder carries an optional sessionId, set by stampSession and never sent. posOrderSchema is now version 1 (the optional sessionId): create pos_orders with the new posOrderCollection(), which carries its identity migration; with posOrderSchema alone, RxDB refuses the collection.

    A closed session is final: nothing moves it out of closed (RegisterSessionClosedError), a repeat closeSession is a no-op, recordMovement and voidMovement take the sessions collection first and refuse a missing or closed session, and writeClosure refuses a session that is not closed.

  • #105 6281345 Thanks @kilbot! - Orders with a line in its own tax mode now finalize; the server charges each line in its own mode (ADR-038 amendment 2).

  • #74 0121559 Thanks @kilbot! - Removes the unreleased multi-tab database machinery in favour of one live tab per store (ADR-061): CreateDatabaseOptions.multiInstance (createTallyDatabase always passes multiInstance: false), the tally-outbox-flush and tally-outbox-state local documents, and follower forwarding between tabs are all gone. The outbox's public API (flush, requeue, start, stop, state$) and its single-instance behaviour are unchanged. Apps enforce one live tab with startLiveTab.

    @tallyui/storage-sqlite's worker now swallows the ready promise's rejection so a pool install failure before any createStorageInstance call doesn't surface as an unhandled rejection, and its files list no longer publishes test files, matching @tallyui/database and @tallyui/pos.

  • #55 350967d Thanks @kilbot! - Stock reads use the reconciled overlay (ADR-060) and show how fresh it is. @tallyui/core now holds withStockOverlay and getProductStock (@tallyui/pos re-exports them), adds STOCK_LEVELS_LAST_PASS, stockOverlay and stockOverlayAsOf props on ConnectorProvider, and a useProductStock(doc) hook that returns overlay stock plus asOf, or getStock(doc) when no overlay is given. @tallyui/database: startStockReconcile also returns state$ (running, truncated, lastError, lastCompletedAt), reconcileStock() resolves with completedAt, and each successful pass stores { completedAt } in the last-pass local document of stock_levels, which createTallyDatabase now creates with local documents (apps that create the collection themselves use the new stockLevelsCollection config; without local documents a pass rejects with a clear error); a restarted runner seeds lastCompletedAt from it. @tallyui/pos adds stockOverlayAsOf$. ProductStockBadge reads stock through useProductStock and appends " · as of <time>" when an overlay is given (showAsOf={false} hides it).

  • #53 e3b8686 Thanks @kilbot! - Add a stock reconcile pass (ADR-060). @tallyui/core adds the StockReconcileAdapter contract (fetchPages and a pure overlay) and an optional reconcile.stock on TallyConnector. @tallyui/database adds the local-only stock_levels collection (STOCK_LEVELS_COLLECTION, stockLevelsSchema), which createTallyDatabase creates for connectors with reconcile.stock, and startStockReconcile, which re-reads stock every 5 minutes (and on demand through reconcileStock()) into that collection: it writes only changed rows, removes keys the backend no longer returns, writes nothing after a failed, truncated or stopped read, and never writes the replicated products. @tallyui/pos adds stockOverlay$, withStockOverlay and getProductStock, which read stock from the overlay where it has an entry and from the replicated product otherwise. The Vendure connector reconciles variant stockLevels, and the Medusa connector reconciles inventory item location levels, so stock changes that bump no product timestamp reach the POS.

  • #106 e7d3033 Thanks @kilbot! - @tallyui/pos adds the neutral store-settings bootstrap every platform POS shares. resolveStoreSettings reads the app's stored choice, calls connector.storeSettings, returns choose with the choices on choice_required (the tried choice as initial), and saves a new pick only once it resolves. useStoreSettings wraps it as a hook that re-resolves on a store switch and discards stale results, with choose(choice) and retry(). withPricingContext and taxProviderProps map the settings into the replication's SyncContext and <TaxProvider>. The app keeps persistence through loadChoice and saveChoice.

  • #118 5bcabfb Thanks @kilbot! - Add the tender reducer, ported from WCPOS next at test parity: an integer-minor-unit keypad, cash change, quick tender amounts, and even/fixed/percentage/item split plans. tenderReducer, initTenderState, initialTenderState, appliedMinor, changeMinor, quickTenderedAmounts, evenSplitShareMinor, activePlan, planLegs and MAX_TENDER_MINOR are exported from @tallyui/pos, along with their state and action types. WCPOS's WooCommerce-only legacy tab (TenderTab, the tab field, set-tab) is dropped as platform-specific; PaymentTransport is redefined locally as the same hardware transport union. No screens: those come with the TV6 components lift and the app.

  • #104 bc46d99 Thanks @kilbot! - ProductGrid hides products this channel doesn't sell (traits.isSellable false, for example a Medusa product outside the sales channel) unless showUnsellable is set, and ProductCard shows such a product in a muted "Not sold here" state instead of its price. OrderBuilder.addProduct now refuses an unsellable product before looking up its price, instead of throwing the unrelated "No price in …" error.

  • #143 fb6c2e3 Thanks @kilbot! - Add useRegisterSession, the till's register session as React state with its actions (open, count, back to selling, close, cash movements, voids). The app supplies every input. Expected cash and the sales count are derived locally from pos_orders, and nothing is sent to a server. requireOpen() gates tender. RegisterTenderInProgressError stops counting or closing during a sale at tender, RegisterSessionAlreadyOpenError stops a second open, and RegisterCloseIncompleteError stops an open until an interrupted close is finished. A resumed close uses the count saved on the session.

Patch Changes

  • #155 96bf8f7 Thanks @kilbot! - addPosOrderCollection(db): when a close stops waiting (after POS_ORDER_MIGRATION_CLOSE_WAIT_MS) while the migration still runs, the migration no longer writes into the stores the close closes. On SQLite it used to fail with a raw SQLite.bulkWrite() already closed and an unhandled rejection, and left the SQLite handle unable to write until restart. The open now rejects with PosOrderOpenClosedError (code: 'POS_ORDER_OPEN_CLOSED'), and the next open on the same database migrates every order.

    The status writes such an open drops are logged at warn through the new exported posOrdersLogger (scope pos-orders), and a close that gives up once the migration is done, while the open reads its status, also rejects with PosOrderOpenClosedError.

  • #174 160252c Thanks @kilbot! - ClosureSheet shows who approved the close (Approved by {name}, falling back to the approver id without a name) under the figures, blind or not — it's provenance, not a counted figure. RegisterColumn offers "Finish closing" when useRegisterSession's session is closed but its closure row was never written (an interrupted close), resuming the close (the store keeps the count persisted on the session) instead of falling through to the cart, where openSession would otherwise refuse with RegisterCloseIncompleteError. buildClosureDocument's return type now carries closure.unsynced_count: number and each movement's id/reason as string, without a cast; no runtime output changed.

  • #147 cbda7e0 Thanks @kilbot! - useSale() follow-ups to #145's idempotent complete(): the sale now locks from complete()'s entry (saving true, every change refused with SALE_SAVING), not only once the order is built and stamped — so Back, an edit or cancelTender() during the session stamp is refused, and a finalizeOrder refusal before any order is built still unlocks the sale with the refusal's error, as before. newSale() now also clears the in-flight save's tracking and stamps a new generation: a hung save's late outcome can no longer make the next sale's complete() quietly share its promise, and an attempt still building or stamping when newSale() lands can no longer install its order as the new sale's pending completion — that order still reaches onSaleCompleted once it's built, since the money is already taken; only a throw from that background call is new, logged at error rather than shown to the new sale. The order outbox's run() now checks the stored order's commandId before marking it sent or rejected (not only its syncStatus), and reads it with a primary-key lookup straight on the storage instance instead of a full index scan, so an order requeued under a new commandId while its old command was in flight can't take the old result.

  • #145 e103c71 Thanks @kilbot! - useSale().complete() is idempotent for one tender. It builds the order once per tender attempt and keeps it as the pending completion. A retry after onSaleCompleted throws hands over the same order (the same id, commandId and createdAt, with no second session stamp or late-sale fact), so a save that failed after storing the order no longer queues a duplicate sale. A second complete() while one is in flight (a double tap) returns the first call's promise instead of building a second order, and complete() on the receipt does nothing. While a completion is pending, the new saving flag is true and the sale is locked: add, setQuantity, remove, applyDiscount, removeDiscount, setTender, startTender and cancelTender change nothing and set the error "This sale is being saved. Retry to finish it." newSale() abandons it, and leaves any order already stored in pos_orders untouched. useOrderOutbox().record treats an order already stored under the same commandId as stored, and still flushes; the same id under another commandId still rejects.

  • #129 350dd72 Thanks @kilbot! - Stacked line discounts now record what each one actually removed, capped at what the earlier discounts on the same line left, instead of the discount's raw computed amount. A line's discountMinor is unchanged; only the per-discount amountMinor breakdown the receipt will print is corrected (#63).

  • #152 7e489e0 Thanks @kilbot! - addPosOrderCollection(db): a close now waits for the whole open (up to POS_ORDER_MIGRATION_CLOSE_WAIT_MS), not only its migration. A close that landed while the open added the collection or reset the migration checkpoint used to close storage under it; on SQLite the open then failed with rxdb-premium's raw ReferenceError: context is not defined. An open called on a database whose close has begun, or one the close stopped waiting for, now stops before any further write and rejects with the new exported PosOrderOpenClosedError (code: 'POS_ORDER_OPEN_CLOSED'): reopen and call it again.

  • #133 f67535d Thanks @kilbot! - addPosOrderCollection(db) follow-ups from the #131 review: a close no longer waits forever on a stuck migration (it gives up after POS_ORDER_MIGRATION_CLOSE_WAIT_MS, 10s, leaving the worst case an ERROR status the next open safely resets and retries); it refuses a multiInstance database up front, before any reset, since the reset can race a second tab's migration (TallyUI is single-instance, ADR-061); and repeated DM4 retries on the same database no longer add another db.onClose handler each time, only ever one.

  • #135 d921415 Thanks @kilbot! - addPosOrderCollection no longer lets a failed migration run's replication outlive the run (RxDB's cancel() never stops it), so rapid DM4 retries on the same database raise no unhandled removed already rejection. A version-0 order whose version-1 copy is stale (for example, sent by a rolled-back build after a failed run copied it as pending) now migrates with its newer version-0 state instead of losing to the stale copy or looping in RxDB's conflict handling.

  • #95 f1af98c Thanks @kilbot! - OrderCreateLine gains an optional taxInclusive (ADR-038 amendment 2). It is the line's own tax mode, sent only when that mode differs from the order's pricesIncludeTax. Single-mode orders produce byte-identical payloads. finalize still rejects converted lines until the Medusa plugin honours the field.

  • #158 a7fdde8 Thanks @kilbot! - A sale stamped with a register session that then closed, and stored after that session's closure was frozen without it, no longer sits on no Z. The new sweepOrphanStamps turns each such order into a late sale: it removes sessionId, sets lateSessionId and logs one late-sale fact. It never changes the closure, an order the closure lists, an order whose session has no closure yet, a late order or another register's orders. useRegisterSession runs it on start, after each close's closure, and whenever a new closure appears. voidMovement takes an optional closures collection and re-reads the session after its writes. If the session closed meanwhile and no closure lists the reversal, it throws RegisterMovementStrandedError, and it always does so when closures isn't passed. The reversal is kept either way.

  • #146 5e5ba11 Thanks @kilbot! - The order outbox reads its pending batch, its pending count and the rejected orders to requeue straight from the storage, past RxDB's query cache. In RxDB 16.21.1 a sale inserted while a cached query's storage read was in flight never reached that query, so the outbox left it unsent until the app restarted. Before patching a sent order, the outbox now checks the order's stored state the same way, because a findOne(id) can go stale too. New exports readFresh(collection, query) and countFresh(collection, selector) do these reads.

  • #38 6444868 Thanks @kilbot! - uuidv7() with no arguments is now monotonic: ids made in the same millisecond, or after the clock steps back, still sort strictly after the previous one (RFC 9562 monotonic random counter). Calls with an explicit timestamp or random source are unchanged.

  • #51 7502d61 Thanks @kilbot! - addProduct charges the chosen variant's price (and SKU) instead of the first variant's; an unknown variant id throws. requeue() re-checks each order's status inside the write, so it never re-pends an order that is no longer rejected.

  • #157 125c85a Thanks @kilbot! - readFresh, countFresh and watchFresh move to a new, side-effect-free subpath, @tallyui/core/rxdb. Core now lists rxdb (>=16) and rxjs (>=7) as optional peer dependencies, needed only by that subpath; core's main entry stays free of both. @tallyui/pos re-exports the helpers unchanged. The id and fingerprint reconciles in @tallyui/database read the local products with readFresh instead of a cached find(), so a product the pull inserts or deletes while a pass reads them no longer leaves every later pass reading a stale list (RxDB 16.21.1 bug 4): an inserted product is now checked, and tombstoned or re-fetched, on the next pass, and a deleted one is no longer re-enqueued or counted towards the mass-delete brake.

  • #128 b32d1b4 Thanks @kilbot! - Resuming a parked order now keeps each line's own tax mode (taxInclusive) instead of falling back to the store's mode. Previously a parked mixed cart came back with every line priced in the store's mode, changing both the settlement figures (subtotalMinor, discountMinor, taxMinor, totalMinor) and the display figures from the ones the cashier parked.

  • #170 93ed2cc Thanks @kilbot! - useSale now stamps the session in force when the tender started (startTender), pinned for that tender, instead of reading its session option at complete() time. A session closed between startTender and complete() (so the app's saleSession went undefined) previously skipped the stamp entirely: the order got neither sessionId nor lateSessionId and no late-sale fact. It now becomes a late sale on the pinned session. A tender started with no session stays unstamped, even if a session appears before complete(). The pin is dropped by cancelTender() and newSale().

  • #156 244bb46 Thanks @kilbot! - stampSession, recordMovement and voidMovement check that the session is live with a primary-key storage read instead of a cached findOne, and so does recordMovement's re-read after its insert. A session closed by a write that skips that cached query, as a server sync would, is no longer taken as open until the app restarts.

  • #150 a9a4525 Thanks @kilbot! - useSale().newSale() now refuses (with the saving error, changing nothing) while a complete() attempt is still building or stamping its order — not only once a pending completion exists. Previously that window let newSale() abandon the attempt and start a new sale at once, handing the built order to onSaleCompleted in the background once it finished; a failure there was money taken with only an error log. Now the cashier waits for the short stamp, then gets Retry or Continue as usual, and abandoning a save is possible only once its order is confirmed stored (continueSale()) or from the receipt. The generation-mismatch hand-over this replaces, and handOverAbandoned, are removed. Also fixes three gaps the #149 review found no test caught: complete() now clears a stale canContinue at every new attempt's entry (including a Retry after Continue was offered), and a confirmation from isStored that arrives after the completion is no longer pending, or after a newer attempt has started, no longer sets canContinue.

  • #160 787cada Thanks @kilbot! - The orphan-stamp sweep (sweepOrphanStamps, ADR-032) is now bounded by a swept_closure_ids set on the register document: a closure it has already checked costs no pos_orders query, and the set needs no schema bump. It now takes register and storeKey, as writeClosure does. A closure joins the set only once it is older than the new SWEEP_GRACE_MS, so an insert racing a close still gets caught; useRegisterSession runs a full sweep (ignoring the set) once on start to repair anything a save that outlasts the grace missed. closeSession already awaited its own sweep before returning; that is now covered by a test. voidMovement's closure lookup is now a primary-key storage read, for consistency with readSession.

  • #172 3e63452 Thanks @kilbot! - useSale's startTender takes the confirmed session (startTender(method, { session })) and pins it instead of the rendered session option, which can lag a session opened just before the tender. useRegisterSession adds requireSaleSession(): requireOpen(), returning { id, sessions } to pass to startTender. A tender that pinned no session, with a session rendered by complete(), now stamps that current session and logs a warning, instead of leaving the order unstamped.

  • #166 07e0198 Thanks @kilbot! - varianceText puts the direction in the word only: "€100.00 short", "€5.00 over", "Exact", without a leading sign (a signed "−€100.00 short" read as a double negative).

  • #153 38df38a Thanks @kilbot! - watchFresh re-reads once per bulk write instead of once per document: it listens to collection.eventBulks$ rather than the per-document collection.$, so a bulkInsert of 100 documents costs one storage read, not 100. (RxDB Premium's SQLite storage splits a write into batches of 199 documents and emits one event per batch, so a larger write costs one read per batch.)

@tallyui/theme

Patch Changes

  • #88 5053527 Thanks @kilbot! - no longer publishes test files

  • #36 d1bc892 Thanks @kilbot! - Light theme contrast: primary darkened to #5b5ef0 (white text 4.88:1), muted-foreground to #656c79 and input to #848a94 (control borders ≥ 3:1). Product cards and quick-tender buttons get borders, and components no longer use the undefined bg-surface-alt or text-muted text classes, so labels such as "Change Due" are legible.

  • #37 12b7723 Thanks @kilbot! - Status colours pass WCAG AA with their foregrounds: light success #047857, warning #b45309, info #2563eb (and price #047857). Dark theme: input borders #636c76 (≥ 3:1), border #3d444d, and destructive text is dark (#0d1117) on the red. The contrast test now covers status colours and the dark theme.

  • #31 78cada7 Thanks @kilbot! - tokens.css now defines the light theme in a @variant light block, alongside @variant dark. Uniwind requires every theme to set the same variables, so apps using Uniwind no longer print 27 "Theme light is missing variable" errors per bundle. The file also declares the light custom variant, so it still compiles with plain Tailwind 4. Colour values are unchanged.

@tallyui/connector-woocommerce

Major Changes

  • #15 807d8da Thanks @kilbot! - Product replication adapters are now pull-only. The push handler is removed from medusaProductReplication, wooProductReplication, vendureProductReplication and shopifyProductReplication. Catalogue data is server-owned, so the POS never writes products. The old push handlers also turned every HTTP or network error into a fake conflict, which made RxDB silently revert local edits.

    This removes a public member. Nothing in TallyUI called it, but code that called adapter.push directly must stop doing so.

Minor Changes

  • #11 f90e59d Thanks @kilbot! - Add backend-neutral price and stock traits. ProductTraits gains getPrices (a price list of integer minor-unit Money entries, base or sale, per currency) and getStock (in_stock | out_of_stock | backorder | unknown plus an optional quantity). Core adds resolvePrice, moneyFromMajor, moneyToMajor and minorUnitDigits. Every connector maps its own shape into them; WooCommerce's instock/outofstock/onbackorder strings now stay inside the WooCommerce connector. The string-price and WooCommerce-style stock accessors remain and are deprecated.

  • #11 bc0a224 Thanks @kilbot! - Add isSellable and getVariantCount product traits to core and all four connectors.

Patch Changes

@tallyui/connector-medusa

Major Changes

  • #15 807d8da Thanks @kilbot! - Product replication adapters are now pull-only. The push handler is removed from medusaProductReplication, wooProductReplication, vendureProductReplication and shopifyProductReplication. Catalogue data is server-owned, so the POS never writes products. The old push handlers also turned every HTTP or network error into a fake conflict, which made RxDB silently revert local edits.

    This removes a public member. Nothing in TallyUI called it, but code that called adapter.push directly must stop doing so.

Minor Changes

  • #54 50317c8 Thanks @kilbot! - Connectors can sign a user in: ConnectorAuth gains an optional signIn(baseUrl, { email, password }, init?) that resolves to a SignInResult (token, optional ISO 8601 expiresAt) and rejects with a SignInError whose code is invalid_credentials, unsupported or failed. The app stores the token and passes it back to getHeaders as token.

    Vendure's auth gains a sign-in flow: it runs the Admin API login mutation and takes the token from the vendure-auth-token header (the server's tokenMethod must include 'bearer'). Its fields are now url, email, password and an optional channel_token. getHeaders sends credentials.api_key as vendure-api-key, otherwise credentials.token as a Bearer token, plus vendure-token when channel_token is set. The old auth_token credential is still accepted as a deprecated alias for token.

    Medusa's medusaAdminUserAuth signs in through POST /auth/user/emailpass and reads expiresAt from the JWT's exp. medusaSecretKeyAuth has no sign-in.

  • #85 609ebd8 Thanks @kilbot! - Add the fingerprint reconcile (ADR-060 amendment 8): a neutral runner that compares a remote fingerprint per product against the local documents and re-delivers products whose fingerprint differs, through the collection's pull. @tallyui/core adds the FingerprintReconcileAdapter contract (fetchPages, a pure fingerprint and enqueue) and an optional reconcile.prices on TallyConnector. @tallyui/database adds startFingerprintReconcile, which runs no pass at start by default and otherwise mirrors the id reconcile: a complete, successful pass only, state$ (running, lastResult, lastError), and stop(). @tallyui/connector-medusa adds reconcile.prices, a nightly base-price backstop (MEDUSA_PRICE_RECONCILE_INTERVAL_MS) for the variant feed (ADR-060 job D1): it fingerprints each product's base prices (variant id, currency and amount, sorted, price-list prices excluded) from /admin/product-variants. Nothing is written locally; corrections arrive only through the reconcile feed's pull.

  • #35 63f11fa Thanks @kilbot! - Add a Bearer credential type for Medusa admin users: medusaAdminUserAuth sends the JWT from emailpass sign-in as Authorization: Bearer <jwt>, and medusaAdminUserConnector is medusaConnector with that auth. The secret-key auth is now also exported as medusaSecretKeyAuth; medusaConnector is unchanged.

  • #92 945bb83 Thanks @kilbot! - Medusa prices as Medusa charges them (ADR-060 D2b). SyncContext gains an optional pricingContext (from storeSettings()), ProductPrice an optional taxInclusive, and TallyConnector.reconcile a calculatedPrices slot. With a pricing context, every Medusa product document build fills each variant's calculated_price from the store API (null when the sales channel or region does not sell it), and the traits price from it: sale lists as a sale against the original price, override lists as the base price, null as unsellable. reconcile.calculatedPrices re-delivers products whose calculated prices changed with no timestamp bump; run it every MEDUSA_CALCULATED_PRICE_RECONCILE_INTERVAL_MS (30 minutes) with maxPages: 1000. Without a pricing context, documents and prices are unchanged.

  • #63 d9fe1e3 Thanks @kilbot! - Fix the Medusa connector's incremental pull: Medusa 2.21 honours only the operator form updated_at[$gte], and silently ignored the connector's updated_at[gte], so every pass read the whole catalogue. Add the Medusa id reconcile (ADR-060), so a deleted product or a deleted variant (whose parent's updated_at does not change) now reaches the local copy through replication.products and reconcile.ids. @tallyui/database adds a mass-deletion brake to startIdReconcile: a pass that would tombstone more than maxDeleteShare (default 20%) of local products, and more than 10 of them, queues nothing and warns instead, unless allowMassDelete is set.

  • #102 7490a3f Thanks @kilbot! - A connector schema version bump now drops and resyncs its collection (ADR-060 amendment 9). createTallyDatabase adds RxDB's migration-schema plugin and gives each connector collection above version 0 a v => null strategy per earlier version, and startReplication appends -v<version> to the replication identifier above version 0, so the pull starts from no checkpoint. Version 0 collections keep their identifier and never resync. stock_levels and pos_orders are untouched.

    The Medusa products schema is now version 1 and declares variants[].calculated_price (object or null). The first sync after upgrading resyncs the Medusa catalogue: the stored products are dropped when the database opens and download again, once, on the first sync. A collection created with medusaProductSchema outside createTallyDatabase must use connectorCollection(medusaProductSchema) from @tallyui/database, which supplies the strategies and the migration plugin; otherwise RxDB throws COL12.

  • #89 4c2cf8f Thanks @kilbot! - medusaConnector.storeSettings (TV4b) reads Medusa's admin API only: the resolved region's currency and price-preference tax inclusivity, the resolved country's tax region default rate (rounded to integer ppm once, at the connector's edge; 0 with no tax region or no default rate, matching what Medusa's system provider itself charges), and a pricingContext (region_id, currency_code, publishable_key) for pricing through the store API. Region, then country, then channel (the publishable key, excluding revoked ones): the first ambiguity reports every choice known at that point, as StoreSettingsError('choice_required'), so the app asks once; a store with no publishable key at all rejects with StoreSettingsError('failed'). The publishable key is a public credential and may sit in pricingContext, but never appears in an error message, choices, or console output.

  • #81 03cd385 Thanks @kilbot! - Medusa's replication.products now includes a variant feed, so price edits arrive incrementally. Medusa 2.21 bumps a variant's updated_at on a price-only edit but not its product's, so the product feed alone missed those changes. The variant feed pages changed variants and re-delivers their parent products. The first sync after upgrading re-delivers every product once.

  • #11 f90e59d Thanks @kilbot! - Add backend-neutral price and stock traits. ProductTraits gains getPrices (a price list of integer minor-unit Money entries, base or sale, per currency) and getStock (in_stock | out_of_stock | backorder | unknown plus an optional quantity). Core adds resolvePrice, moneyFromMajor, moneyToMajor and minorUnitDigits. Every connector maps its own shape into them; WooCommerce's instock/outofstock/onbackorder strings now stay inside the WooCommerce connector. The string-price and WooCommerce-style stock accessors remain and are deprecated.

  • #120 e0062ce Thanks @kilbot! - A per-store order.create capability check replaces the global discount guard (ADR-062). @tallyui/core gains ServerCapabilities, SignInResult.capabilities, SyncContext.capabilities, TallyConnector.capabilities?() and resolveCapabilities(fresh, stored). @tallyui/connector-medusa reads the store's supported order.create versions from GET /tally/v1/info: a 404 or a malformed response means an old plugin (version 1), a network failure or a 5xx is unknown and keeps the last known value, and a 401 throws. medusaSignIn returns the read capabilities, and both Medusa connectors expose capabilities(context) for a restored session. finalizeOrder in @tallyui/pos now rejects a discount only when the store's capability is below 2, so a store whose plugin has caught up finalizes a discounted order as order.create version 2.

  • #11 bc0a224 Thanks @kilbot! - Add isSellable and getVariantCount product traits to core and all four connectors.

  • #57 55ae68f Thanks @kilbot! - SignInErrorCode splits the old failed in two: failed now means no response arrived (a network error), and the new server_error means a response arrived but was unusable (a bad status, a malformed body, or a missing token). SignInError gains an optional status from a third constructor argument. Callers that switch on code should handle server_error.

    Medusa's sign-in now treats mfa_required: true and verification_required: true the same as a location body: unsupported, and no token is ever returned from a body like that. A malformed response body, any other non-OK status and a missing or non-string token are now server_error with the HTTP status.

    Vendure's sign-in now treats NATIVE_AUTH_STRATEGY_ERROR as unsupported, since native email/password auth is disabled on the server. A malformed response body, a non-OK status, GraphQL errors, a missing data.login and any other ErrorResult are now server_error with the HTTP status.

  • #64 402ec36 Thanks @kilbot! - Both connectors now store a product's variants sorted by id, since neither Medusa nor Vendure guarantees variant order across requests: @tallyui/core adds compareIds, and the Medusa and Vendure product projections (toDocument, toProductDocument) sort variants with it before the document is stored. Traits that read variants[0] (getPrices, getSku, getPrice, getStockQuantity, getBarcode and others) now see a stable variant across runs.

    Already-stored documents take the new order the next time they are delivered. Vendure's variant feed re-delivers every product on its first pass anyway, so it heals immediately.

  • #53 e3b8686 Thanks @kilbot! - Add a stock reconcile pass (ADR-060). @tallyui/core adds the StockReconcileAdapter contract (fetchPages and a pure overlay) and an optional reconcile.stock on TallyConnector. @tallyui/database adds the local-only stock_levels collection (STOCK_LEVELS_COLLECTION, stockLevelsSchema), which createTallyDatabase creates for connectors with reconcile.stock, and startStockReconcile, which re-reads stock every 5 minutes (and on demand through reconcileStock()) into that collection: it writes only changed rows, removes keys the backend no longer returns, writes nothing after a failed, truncated or stopped read, and never writes the replicated products. @tallyui/pos adds stockOverlay$, withStockOverlay and getProductStock, which read stock from the overlay where it has an entry and from the replicated product otherwise. The Vendure connector reconciles variant stockLevels, and the Medusa connector reconciles inventory item location levels, so stock changes that bump no product timestamp reach the POS.

  • #19 8df0569 Thanks @kilbot! - Adds variant traits. VariantSummary (id, title, sku, barcode, prices, stock) and the optional ProductTraits.getVariants describe every purchasable variant of a product, and findVariantByCode finds a variant by barcode or SKU for scanning. The Medusa connector implements getVariants; its product-level getPrices and getStock results are unchanged.

Patch Changes

  • #48 6b1b0b7 Thanks @kilbot! - Product replication no longer loses updates inside RxDB's replication loop: a pass ends on the list count, the next pass starts from a high-water mark read at the pass start, an unchanged mark ends the loop, and a pass restarts if rows vanish mid-pass.

  • #97 f8b0dac Thanks @kilbot! - A fresh install downloads the catalogue once. A pull adapter can now declare pull.seedCheckpoint; on a fresh install (no stored checkpoint) combinePullAdapters reads every seed before any feed runs and starts that feed from it. The Medusa and Vendure variant feeds seed their cursor at the newest variant's updated_at, so their first pass no longer re-delivers every product the product feed has just delivered, and a variant edit made during the product feed's first pass still arrives. An install upgrading from a stored checkpoint is never seeded and keeps the variant feed's full healing pass.

  • #98 9cd78cd Thanks @kilbot! - medusaStoreSettings's choice_required channel choices are named after the publishable key's sales channel(s) (joined with ", " when there is more than one), not the key's own developer-facing title. A key with no sales channel, or only blank channel names, still falls back to its title.

  • #11 4e6261f Thanks @kilbot! - Fix product replication skipping a page of products per batch: the checkpoint now keeps its updated_at filter fixed while paging and only advances it at the end of a pass.

  • #11 ffa9f19 Thanks @kilbot! - Fix the Medusa connector against a real Medusa v2 (2.21) backend: send the secret API key over HTTP Basic auth (Medusa rejects it as a Bearer token), read prices as major units (v2 does not store cents), derive stock from inventory levels (the Admin API does not compute inventory_quantity), and pull products in updated_at order so the checkpoint is valid.

  • #111 35a3fba Thanks @kilbot! - A chosen country outside the region is reported as choice_required instead of silently using the region's only country.

  • #11 60230f0 Thanks @kilbot! - Make the Medusa product schema load under RxDB dev-mode (indexed handle and status are now required with a maxLength), keep pulled documents to the schema's fields so new Medusa API fields never fail validation, and page each replication pass in id order, since many products share an updated_at.

  • #88 5053527 Thanks @kilbot! - no longer publishes test files

  • #94 373e438 Thanks @kilbot! - resolvePrice keeps a price's taxInclusive flag on current and was. Each order-builder line keeps its price's own tax mode (LineItem.taxInclusive, plus priceTaxModeConverted when it differs from the store's pricesIncludeTax), so a customer pays exactly the shelf price and an inclusive price in an exclusive store is no longer taxed twice. Orders whose prices carry no flag, or one that agrees with the store, total exactly as before. The receipt shows a converted line in the order's mode, by its share of the order's once-rounded tax, so the lines still add up.

    In priced mode, the Medusa traits' deprecated getPrice and getRegularPrice return the resolved calculated price instead of the admin prices, and isSellable is false when no variant yields a price (for example a calculated_price with null amounts).

  • #11 b1b6e30 Thanks @kilbot! - Report product-level stock across all variants, showing the total quantity only when every variant has tracked, known stock. Draw the search magnifier with an attached, rounded handle and a larger ring.

@tallyui/connector-shopify

Major Changes

  • #15 807d8da Thanks @kilbot! - Product replication adapters are now pull-only. The push handler is removed from medusaProductReplication, wooProductReplication, vendureProductReplication and shopifyProductReplication. Catalogue data is server-owned, so the POS never writes products. The old push handlers also turned every HTTP or network error into a fake conflict, which made RxDB silently revert local edits.

    This removes a public member. Nothing in TallyUI called it, but code that called adapter.push directly must stop doing so.

Minor Changes

  • #11 f90e59d Thanks @kilbot! - Add backend-neutral price and stock traits. ProductTraits gains getPrices (a price list of integer minor-unit Money entries, base or sale, per currency) and getStock (in_stock | out_of_stock | backorder | unknown plus an optional quantity). Core adds resolvePrice, moneyFromMajor, moneyToMajor and minorUnitDigits. Every connector maps its own shape into them; WooCommerce's instock/outofstock/onbackorder strings now stay inside the WooCommerce connector. The string-price and WooCommerce-style stock accessors remain and are deprecated.

  • #11 bc0a224 Thanks @kilbot! - Add isSellable and getVariantCount product traits to core and all four connectors.

Patch Changes

  • #88 5053527 Thanks @kilbot! - no longer publishes test files

  • #11 b1b6e30 Thanks @kilbot! - Report product-level stock across all variants, showing the total quantity only when every variant has tracked, known stock. Draw the search magnifier with an attached, rounded handle and a larger ring.

@tallyui/connector-vendure

Major Changes

  • #15 807d8da Thanks @kilbot! - Product replication adapters are now pull-only. The push handler is removed from medusaProductReplication, wooProductReplication, vendureProductReplication and shopifyProductReplication. Catalogue data is server-owned, so the POS never writes products. The old push handlers also turned every HTTP or network error into a fake conflict, which made RxDB silently revert local edits.

    This removes a public member. Nothing in TallyUI called it, but code that called adapter.push directly must stop doing so.

Minor Changes

  • #54 50317c8 Thanks @kilbot! - Connectors can sign a user in: ConnectorAuth gains an optional signIn(baseUrl, { email, password }, init?) that resolves to a SignInResult (token, optional ISO 8601 expiresAt) and rejects with a SignInError whose code is invalid_credentials, unsupported or failed. The app stores the token and passes it back to getHeaders as token.

    Vendure's auth gains a sign-in flow: it runs the Admin API login mutation and takes the token from the vendure-auth-token header (the server's tokenMethod must include 'bearer'). Its fields are now url, email, password and an optional channel_token. getHeaders sends credentials.api_key as vendure-api-key, otherwise credentials.token as a Bearer token, plus vendure-token when channel_token is set. The old auth_token credential is still accepted as a deprecated alias for token.

    Medusa's medusaAdminUserAuth signs in through POST /auth/user/emailpass and reads expiresAt from the JWT's exp. medusaSecretKeyAuth has no sign-in.

  • #45 14620d9 Thanks @kilbot! - Fix Vendure product pagination with fixed timestamp windows and ID ordering. Add an opt-in barcode field and stock-location configuration, use available stock from stockLevels, and support the Admin API in the mock.

    Complete pull passes using totalItems, restart empty mid-pass pages, and use a pass-start high-water mark with idle detection to preserve updates in RxDB replication. Include GraphQL error messages on failed HTTP responses. Existing users must set barcodeField to read barcodes: getBarcode now returns undefined unless barcodeField is configured.

    Guard each pull pass against skewed Vendure updatedAt filters and add updatedAtSkewMs to widen lower bounds when the server cannot run with TZ=UTC.

  • #58 3e09957 Thanks @kilbot! - Add combinePullAdapters to @tallyui/core: it combines several pull adapters into the one adapter a collection replicates with, calling them one after another with a checkpoint per sub-adapter. Two replications on one collection can skip each other's pulled versions, so run one per collection.

    Vendure's replication.products now includes a variant feed that re-delivers parent products whose variants changed. Vendure does not bump Product.updatedAt on a variant price or stock edit, so the product feed alone misses those changes. An existing install's product-feed checkpoint carries over; the variant feed runs one full pass on first sync.

  • #50 2424107 Thanks @kilbot! - Add variant summaries for barcode scanning and variant pickers. Add a pricesIncludeTax option, defaulting to false, so product and variant prices use net amounts by default and gross amounts for tax-inclusive channels.

  • #61 540044c Thanks @kilbot! - Add the id reconcile (ADR-060): a periodic pass that reads every live product id and its live variant ids, so a deleted product or a deleted variant (whose parent's updatedAt does not change) reaches the local copy. @tallyui/core adds the IdReconcileAdapter contract and createReconcileFeed, which turns queued corrections into a pull-only adapter meant as the last key of combinePullAdapters. @tallyui/database adds the startIdReconcile runner. @tallyui/connector-vendure implements the Vendure side and wires it into replication.products and reconcile.ids. Nothing is written locally into the replicated collection; corrections arrive only through the collection's own pull.

  • #11 f90e59d Thanks @kilbot! - Add backend-neutral price and stock traits. ProductTraits gains getPrices (a price list of integer minor-unit Money entries, base or sale, per currency) and getStock (in_stock | out_of_stock | backorder | unknown plus an optional quantity). Core adds resolvePrice, moneyFromMajor, moneyToMajor and minorUnitDigits. Every connector maps its own shape into them; WooCommerce's instock/outofstock/onbackorder strings now stay inside the WooCommerce connector. The string-price and WooCommerce-style stock accessors remain and are deprecated.

  • #11 bc0a224 Thanks @kilbot! - Add isSellable and getVariantCount product traits to core and all four connectors.

  • #57 55ae68f Thanks @kilbot! - SignInErrorCode splits the old failed in two: failed now means no response arrived (a network error), and the new server_error means a response arrived but was unusable (a bad status, a malformed body, or a missing token). SignInError gains an optional status from a third constructor argument. Callers that switch on code should handle server_error.

    Medusa's sign-in now treats mfa_required: true and verification_required: true the same as a location body: unsupported, and no token is ever returned from a body like that. A malformed response body, any other non-OK status and a missing or non-string token are now server_error with the HTTP status.

    Vendure's sign-in now treats NATIVE_AUTH_STRATEGY_ERROR as unsupported, since native email/password auth is disabled on the server. A malformed response body, a non-OK status, GraphQL errors, a missing data.login and any other ErrorResult are now server_error with the HTTP status.

  • #64 402ec36 Thanks @kilbot! - Both connectors now store a product's variants sorted by id, since neither Medusa nor Vendure guarantees variant order across requests: @tallyui/core adds compareIds, and the Medusa and Vendure product projections (toDocument, toProductDocument) sort variants with it before the document is stored. Traits that read variants[0] (getPrices, getSku, getPrice, getStockQuantity, getBarcode and others) now see a stable variant across runs.

    Already-stored documents take the new order the next time they are delivered. Vendure's variant feed re-delivers every product on its first pass anyway, so it heals immediately.

  • #53 e3b8686 Thanks @kilbot! - Add a stock reconcile pass (ADR-060). @tallyui/core adds the StockReconcileAdapter contract (fetchPages and a pure overlay) and an optional reconcile.stock on TallyConnector. @tallyui/database adds the local-only stock_levels collection (STOCK_LEVELS_COLLECTION, stockLevelsSchema), which createTallyDatabase creates for connectors with reconcile.stock, and startStockReconcile, which re-reads stock every 5 minutes (and on demand through reconcileStock()) into that collection: it writes only changed rows, removes keys the backend no longer returns, writes nothing after a failed, truncated or stopped read, and never writes the replicated products. @tallyui/pos adds stockOverlay$, withStockOverlay and getProductStock, which read stock from the overlay where it has an entry and from the replicated product otherwise. The Vendure connector reconciles variant stockLevels, and the Medusa connector reconciles inventory item location levels, so stock changes that bump no product timestamp reach the POS.

  • #87 ac2a24a Thanks @kilbot! - TallyConnector gains an optional storeSettings(context, choice?) (TV4): one read-only call for the store's currency, pricesIncludeTax, taxRatesPpm and an opaque connector-specific pricingContext, so the app can feed the connector's own tax-inclusivity option and the POS TaxProvider from a single source of truth instead of two hand-matched settings. Rejects with a StoreSettingsError (choice_required with choices, or failed).

    Vendure's storeSettings reads the active channel's currency and pricesIncludeTax, and the default tax zone's enabled, non-customer-group rates keyed by tax category id (rounded to integer ppm once, at the connector's edge). default is the isDefault category's rate, or, when none is flagged, the first category Vendure's own taxCategories lists — the same fallback Vendure uses for a variant created without a category — and is 0 when that category has no rate in the zone. createVendureConnector's pricesIncludeTax option is unchanged; pass settings.pricesIncludeTax from storeSettings instead of hand-matching it to the POS.

  • #112 8a3f323 Thanks @kilbot! - Adds a nightly price reconcile pass (reconcile.prices), a fingerprint backstop for tax-rate changes: a zone's rate change for a category moves every affected variant's priceWithTax without bumping the variant's updatedAt, so neither the product feed nor the variant feed re-delivers it (ADR-060).

  • #110 b814bf3 Thanks @kilbot! - Fixes three Vendure stock gaps (backlog 28, from the #45 stock review): a variant with trackInventory FALSE, or INHERIT with the global setting off, is now always in stock; outOfStockThreshold (per variant, or the global one through useGlobalOutOfStockThreshold) is now subtracted from available stock, matching Vendure's own saleable rule; getStockStatus and getStockQuantity now aggregate every variant, as getStock already did, instead of reading variant 0 only.

    Adds vendureGlobalStockSettings(context) for the channel's stock defaults, and createVendureConnector options globalTrackInventory and globalOutOfStockThreshold.

    The Vendure product schema is now version 1, declaring variants[].trackInventory, outOfStockThreshold, useGlobalOutOfStockThreshold and enabled. The first sync after upgrading resyncs the Vendure catalogue once: a schema version bump drops the stored products and downloads them again (ADR-060 amendment 9). A collection created with vendureProductSchema outside createTallyDatabase must use connectorCollection(vendureProductSchema) from @tallyui/database.

Patch Changes

  • #97 f8b0dac Thanks @kilbot! - A fresh install downloads the catalogue once. A pull adapter can now declare pull.seedCheckpoint; on a fresh install (no stored checkpoint) combinePullAdapters reads every seed before any feed runs and starts that feed from it. The Medusa and Vendure variant feeds seed their cursor at the newest variant's updated_at, so their first pass no longer re-delivers every product the product feed has just delivered, and a variant edit made during the product feed's first pass still arrives. An install upgrading from a stored checkpoint is never seeded and keeps the variant feed's full healing pass.

  • #88 5053527 Thanks @kilbot! - no longer publishes test files

  • #11 b1b6e30 Thanks @kilbot! - Report product-level stock across all variants, showing the total quantity only when every variant has tracked, known stock. Draw the search magnifier with an attached, rounded handle and a larger ring.

  • #113 5885373 Thanks @kilbot! - A variant disabled in Vendure (enabled: false, replicated since #110) is no longer offered, priced or counted: getVariants, getPrices, getPrice, getRegularPrice, getStock and getVariantCount now consider live variants only, and isSellable also requires at least one when the product has variants at all. A product with none left is not sellable, so addProduct refuses it (#104).

  • #114 51d4818 Thanks @kilbot! - Vendure pull robustness (backlog 29, 30, 31). The high-water mark and both skew probes now select only id updatedAt, not the full product query. A mid-pass checkpoint in the shape saved before #45's pass-state fields (skip and updatedAt alone) is recognised and normalised into a clean restart of the pass from offset 0, instead of resuming at an offset that no longer lines up with this pull's fixed-window paging. The skew guard's probe re-reads the high-water mark once before throwing, so a product deleted between the mark read and the probe settles into a clean pass instead of an error RxDB then has to retry.

1.0.0

@tallyui/database

Minor Changes

@tallyui/components

Minor Changes

@tallyui/connector-woocommerce

Minor Changes

@tallyui/connector-medusa

Minor Changes

@tallyui/connector-shopify

Minor Changes

@tallyui/connector-vendure

Minor Changes

0.2.0

@tallyui/core

Minor Changes

@tallyui/storage-sqlite

Minor Changes

@tallyui/theme

Minor Changes