Skip to content

PR 9/N (Stage 2): move async queue to common + trivial cleanups - #31

Merged
elisiondan merged 1 commit into
nextfrom
pr/09-async-queue-and-cleanup
May 12, 2026
Merged

PR 9/N (Stage 2): move async queue to common + trivial cleanups#31
elisiondan merged 1 commit into
nextfrom
pr/09-async-queue-and-cleanup

Conversation

@elisiondan

Copy link
Copy Markdown
Member

Summary

First Stage 2 PR. Tasks #25 and #30 from the backlog. Net code: -99 lines.

Move async queue (task #25)

The hook + common were importing useEnhancedAsyncQueue from extensions/module/src/composables/use-async-queue.ts — a Vue-reactive composable. Rollup inlined it at build time, so the hook bundle was shipping Vue's ref/computed/watchEffect runtime (~25 KB minified) for what was essentially Promise.all-with-delays.

Replaced with extensions/common/utilities/async-queue.ts — plain Promise orchestration, same { add, execute } surface. Renamed the factory to createAsyncQueue to signal it's not a Vue composable anymore.

Bundle size impact:

Bundle Before After Delta
extensions/sync-hook/dist/index.js 610 KB 582 KB −28 KB / −4.6%
extensions/module/dist/index.js 386 KB 385 KB −1.2 KB

Removed @vueuse/core from extensions/module/package.json — the old composable was its only consumer in module source.

Trivial cleanups (task #30)

  • Delete unused private method ExportToLocalazyService.loadProject (and the now-orphaned LocalazyApiThrottleService import). It was declared but never called.
  • Delete unused appMode computed in module/components/ConfigNotice.vueisDemo is the only thing consuming getConfig().APP_MODE.
  • Rename catch (e) / catch (e: any) to catch (_e) / catch (_e: unknown) in 6 sites where the error was discarded. Matches the eslint ^_ ignore convention.
  • Delete 5 unused exported types (knip-flagged): ContentTransferSetup, CollectionsTranslatableContent, TranslationStringsTranslatableContent, TranslationStringKeyEntry, CollectionsKeyEntry.

ESLint config tightening

Add caughtErrorsIgnorePattern: '^_' to the no-unused-vars rule so catch parameters follow the same ^_ convention as args/vars. Without it, typescript-eslint@8 defaults flag _e as unused even with argsIgnorePattern set.

Verified

  • npm run check — 49/49 tests, lint clean of unused-vars warnings (was 6, now 0), 99 no-explicit-any baseline remains.
  • npm run build — production minified, bundle sizes as above.
  • npm run knipzero findings (was 3 unused types before this PR; all cleaned).

Open issue surfaced

The husky pre-commit hook breaks on Node < 22.18 because ESLint 10's stylish formatter uses util.styleText.validateStream (Node 22.18+). If a contributor's globally-installed Node is older than 22 and they haven't run nvm use for the shell, the hook fails with TypeError: util.styleText is not a function. The repo's .nvmrc says 22 but git hooks don't honor it automatically.

Added Stage 2 task #36 to make the hook source nvm (or fall back gracefully). For now contributors should ensure nvm use is in effect when committing.

Stage 2 backlog status

# Description Status
25 Move async queue to common ✅ this PR
30 Trivial cleanups ✅ this PR
26 boolean === number fixes pending
27 DirectusApiService missing methods pending
28 fetchDirectusItems generic variance pending
29 Pinia store typing pending
31 @localazy/generic-connector-client bump pending
32 @localazy/languages bump pending
33 TypeScript 5.9 → 6 pending
34 Enable typecheck CI gate pending
35 no-explicit-any cleanup (99 baseline) pending
36 Husky hook Node-version-aware pending

🤖 Generated with Claude Code

…dule, trim unused symbols.

Stage 2 tasks #25 and #30. Net code: -99 lines.

Async queue (task #25):
- Replace extensions/module/src/composables/use-async-queue.ts (Vue-reactive composable, 69 lines) with extensions/common/utilities/async-queue.ts (plain Promise orchestration, ~50 lines). Same surface: { add, execute }. Renamed factory from useEnhancedAsyncQueue to createAsyncQueue to signal it's no longer a Vue composable.
- Update 5 import sites across module / sync-hook / common.
- Removed @vueuse/core from extensions/module/package.json — the old composable was its only consumer in module source.
- Bundle size: sync-hook minified bundle shrank from 610 KB to 582 KB (-28 KB, -4.6%). Vue's ref/computed/watchEffect runtime is no longer shipped with the server-side hook.

Trivial cleanups (task #30):
- Delete unused private method ExportToLocalazyService.loadProject and the LocalazyApiThrottleService import that became dead with it.
- Delete unused appMode computed in module/components/ConfigNotice.vue.
- Rename catch (e) / catch (e: any) to catch (_e) / catch (_e: unknown) in 6 call sites where the error was discarded.
- Delete 5 unused exported types (knip-flagged): ContentTransferSetup, CollectionsTranslatableContent, TranslationStringsTranslatableContent, TranslationStringKeyEntry, CollectionsKeyEntry.

ESLint config:
- Add caughtErrorsIgnorePattern: '^_' so catch-clause parameters follow the same ^_ convention as args / vars.

Verified: npm run check (49/49 tests, lint 100 warnings -- 99 no-explicit-any baseline + 1 no-useless-assignment, 0 errors), npm run build (production minified), npm run knip (clean).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@elisiondan
elisiondan merged commit adfec80 into next May 12, 2026
1 check passed
@elisiondan
elisiondan deleted the pr/09-async-queue-and-cleanup branch May 12, 2026 09:35
elisiondan added a commit that referenced this pull request May 12, 2026
…(drop-in)

Stage 2 task #31. 0.x major bump but the API surface we use (GenericConnectorClient constructor, Services.DIRECTUS, getOAuthAuthorizationUrl) is unchanged. Two consumer sites continue to work as-is.

Pre-existing @localazy/languages 0.1.11 vs 1.0.0 duplication unchanged — task #32 resolves it alongside the community language-mapping feature integration.

Verified: 61/61 tests, lint/format/typecheck clean, production build clean. Module bundle 384.7 -> 384.8 KB.

🤖 Generated with Claude Code
elisiondan added a commit that referenced this pull request May 20, 2026
* PR 1/N (2.0 stack): npm workspaces + Node 22 + SQLite dev

Infrastructure foundation for the 2.0 release. No source-code changes.

- npm workspaces (extensions/common, extensions/module, extensions/sync-hook). Single root lockfile. Removed copy-dist-* scripts, assure-config.mjs, fs-extra dep. Fixed set-config.mjs demo-mode path bug.
- Node 18.18 -> 22. Workflows reference .nvmrc via node-version-file. release.yml drops monorepo-npm-install. qa.yml also runs on PRs to next.
- scripts/dev.mjs replaces docker-compose with local Directus + SQLite under development/data/. Symlink layout in development/extensions/ isolates EXTENSIONS_PATH from extensions/common. Added better-sqlite3 as root devDep. Deleted development/docker-compose.yml, init.sql, mysql/, uploads/, nodemon.json.
- README contribution section rewritten for the new Node 22 + SQLite workflow with credentials.

🤖 Generated with Claude Code

* PR 2/N (2.0 stack): ESLint 10 flat config + Prettier + production-build CI gate

ESLint 10 flat config replaces ESLint 8 + abandoned @vue/eslint-config-airbnb. Prettier 3 added.

- eslint.config.js (flat) with typescript-eslint v8 + eslint-plugin-vue@10 flat presets + eslint-config-prettier. Per-workspace globals (browser for module/common, node for sync-hook).
- Prettier 3 with .prettierrc.json (semis, single quotes, trailing commas, printWidth 140) and .prettierignore. Adds lint:fix, format, format:fix, typecheck scripts.
- TypeScript constraint -> ^5.9.3. vue-tsc and globals added as root devDeps.
- Removes per-package lint scripts; root config covers all workspaces including extensions/common (silently unlinted before).
- Mechanical autofix pass: Prettier reformat + ESLint --fix to drop dead eslint-disable directives that referenced rules from the dropped airbnb config.
- build:module / build:hook drop --no-minify; new build:{module,hook}:dev keep it for the dev watch loop. qa.yml now runs the minified production build, matching what release publishes.

Typecheck script added but CI not yet gated on it -- pre-existing source-level type errors and a @directus/types duplication will resolve in PR 4 + Stage 2.

🤖 Generated with Claude Code

* chore: add CLAUDE.md for AI assistants

Initial CLAUDE.md covering what the repo is, the stack, architecture, local development, the full command list, and a coding conventions rule (avoid as any / as unknown casts unless necessary).

🤖 Generated with Claude Code

* PR 3/N (2.0 stack): Vitest scaffold + tests for utilities and hook sync service

Adds Vitest at the workspace root, 49 tests across 6 files. CI now gates on tests too.

- vitest.config.ts at root; co-located *.test.ts; Node environment.
- Scripts: npm run test, npm run test:watch.
- qa.yml runs lint -> format -> test -> build (production).

Coverage:
- extensions/common/utilities/sleep.test.ts (fake timers).
- extensions/common/utilities/merge-with-arrays.test.ts (array values, object values, edge cases incl. null/undefined source).
- extensions/common/utilities/enabled-fields-service.test.ts (valid/empty/malformed JSON, parametrised non-array inputs, round-trip).
- extensions/common/utilities/localazy-payment-status.test.ts (typed Project/Organization helpers using @localazy/api-client types -- no casts).
- extensions/common/services/localazy-api-throttle-service.test.ts (vi.hoisted + vi.mock for @localazy/api-client and sleep; delegation per method using real request types, token refresh, queue ordering, error isolation).
- extensions/sync-hook/.../translation-strings-synchronization-service.test.ts (spy-on-prototype to isolate orchestration: missing-collections early-return, payment-status abort, happy-path export, no-content skip, deprecation gate, deprecation key-filtering).

Adapted test ideas from community PR #21 where they targeted existing services; their new LanguageMappingService and refactored DirectusLocalazyAdapter remain for Stage 2 when the feature lands.

🤖 Generated with Claude Code

* PR 4/N (2.0 stack): @directus/extensions-sdk 12 → 17, host range ^11, drop Directus 10 shim

Major Directus SDK upgrade. Replaces 12.1.4 with 17.1.4 in both extensions; @directus/constants 12 -> 14; @directus/types 12 -> 15 in common (matches what SDK 17 pulls in transitively, deduplicates the previous SchemaOverview / FieldOverview mismatch). directus:extension.host bumped from ^10.10.0 to ^11.0.0. Drops PR #19's old-user-shim in preRegisterCheck now that Directus 10 is out of scope.

Verified: build (minified production), 49/49 tests, lint + format clean, dev server boots and loads both extensions cleanly under Directus 11.17.4 (/server/health and /admin/ return 200).

4 new typecheck errors from SDK 17's stricter Pinia store typing are deferred to Stage 2 with the other pre-existing source-level type bugs.

🤖 Generated with Claude Code

* PR 5/N (2.0 stack): align Vue ecosystem deps with SDK 17, add extension compatibility tables

Aligns declared devDeps with the versions SDK 17 already pulls in transitively, and adds a per-extension compatibility table.

- extensions/module: vue ^3.4.27 -> ^3.5.0, @vueuse/core ^10.2.1 -> ^14.0.0, pinia ^2.1.4 -> ^2.3.1 (stays on 2.x to match Directus' externalized runtime Pinia).
- extensions/module/README.md and extensions/sync-hook/README.md gain a Compatibility table: 2.x -> Directus ^11.0.0, 1.x frozen at ^10.10.0 with the npm install ...@^1 pin instruction for users who can't move to Directus 11 yet.

Verified: build (minified production), 49/49 tests, lint clean (10 pre-existing warnings), format clean.

🤖 Generated with Claude Code

* PR 6/N (2.0 stack): MIGRATION.md, safe dep bumps, BREAKING CHANGE marker for the 2.0 release

Last infrastructure PR before the 2.0 release. Adds MIGRATION.md, picks up safe dev-dep bumps, includes BREAKING CHANGE: footer so localazy/release@v2's conventional-recommended-bump triggers major when next merges to main.

- MIGRATION.md: 2.x requires Directus 11+; @^1 pin instruction for users staying on 10; compatibility matrix; what changed under the hood (host range, SDK 12->17, Node 22 minimum); no schema migration required; post-upgrade checklist.
- README.md gains an Upgrading from 1.x pointer.
- Safe bumps: @types/node 20->22, better-sqlite3 11->12, globals 16->17, @vueuse/core 14.0->14.3.

Deferred to Stage 2 (touch source): @localazy/languages 1->2, @localazy/generic-connector-client 0.2->0.4, typescript 5.9->6, pinia 3.

Verified: build (minified production), 49/49 tests, lint clean (10 pre-existing warnings), format clean. Version not pre-set in package.json -- bot should bump 1.0.10 -> 2.0.0 from the BREAKING CHANGE marker.

🤖 Generated with Claude Code

* PR 7/N (2.0 stack): aggregate check script + husky pre-commit, drop MIGRATION.md

Polish PR. Adds developer-ergonomics pieces and removes the over-engineered MIGRATION.md.

- New scripts: npm run check (lint + format + test) and npm run check:fix (lint:fix + format:fix).
- husky 9 + lint-staged 16 pre-commit hook on *.{ts,vue,js,mjs,json,md,yml,yaml}. prepare script wires husky into npm install.
- qa.yml collapses lint / format / test into a single npm run check step.
- MIGRATION.md removed -- the extension-level compatibility tables already cover the only user-facing action (upgrade Directus to 11+).

Verified: npm run check passes (49/49 tests, lint clean, format clean); build clean.

🤖 Generated with Claude Code

* PR 8/N (2.0 stack): knip + coverage + no-explicit-any rule

Three quality-of-life pieces, none CI-gated.

Knip:
- knip.json with .scss ignored (Sass @import not traceable), directus/better-sqlite3/sass whitelisted (script + build-time deps), ignoreExportsUsedInFile true.
- npm run knip script.
- Acting on findings: delete 3 unused files (common/services/import-from-localazy-service.ts, module/src/models/directus/internals/{checkbox-tree-choice,fancy-select-item}.ts). Remove unused deps @directus/constants from module and @localazy/languages from sync-hook. Add lodash/axios as runtime deps (were transitive) and @directus/types as devDep in module + sync-hook.

Coverage:
- @vitest/coverage-v8 wired into vitest.config.ts (v8 provider; text + HTML + lcov; extensions/**/*.ts scope).
- npm run test:coverage. Baseline ~9.3% statements / 13.5% functions; informational, not gated.

ESLint:
- @typescript-eslint/no-explicit-any flipped from off to warn. Surfaces 106 baseline warnings (CI passes; warnings don't fail). Stage 2 cleans them incrementally.

CLAUDE.md commands table updated with knip, test:coverage, check, check:fix.

Verified: npm run check (49/49 tests, lint clean, format clean), npm run build (production minified), npm run knip (3 unused exported types remain, folded into Stage 2 task #30).

🤖 Generated with Claude Code

* PR 9/N (Stage 2): move async queue to common + trivial cleanups

First Stage 2 PR. Net code -99 lines. Tasks #25 (async queue move) and #30 (trivial cleanups).

- Replace Vue-reactive useEnhancedAsyncQueue in module with plain-Promise createAsyncQueue in common/utilities. Hook bundle shrinks 610 -> 582 KB (-28 KB).
- Remove @vueuse/core from module deps (was only used by the old composable).
- Delete unused ExportToLocalazyService.loadProject + orphaned LocalazyApiThrottleService import.
- Delete unused appMode in ConfigNotice.vue.
- Rename catch (e) -> catch (_e) in 6 sites + ESLint config gets caughtErrorsIgnorePattern.
- Delete 5 unused exported types in common/models (knip-clean afterwards).

Verified: 49/49 tests, 100 lint warnings (99 no-explicit-any baseline + 1 no-useless-assignment, 0 errors), npm run knip clean. Husky hook Node 22.18+ compat issue surfaced and tracked as Stage 2 task #36.

🤖 Generated with Claude Code

* PR 10/N (Stage 2): clear typecheck errors and gate typecheck in CI

Five Stage 2 tasks (#26-29 + #34). After this, npm run typecheck exits 0 and is wired into npm run check + the CI workflow.

- Source bugs: Settings.automated_deprecation comparison fixed (boolean type vs runtime SQLite 0/1 int). Switched to truthy check; updated test fixture.
- Interface: made fetchDirectusSingletonItem and createField optional on DirectusApi (the hook never needs them). UseDirectusApi in the module narrows them back to required via Required<Pick<>>. fetchDirectusItems is now generic in the impl too.
- Pinia store typing: new extensions/module/src/composables/use-directus-stores.ts wrapper contains the SDK-17 cast at one site. 4 call sites updated.
- npm run check now runs lint -> format -> typecheck -> test. qa.yml renamed accordingly. CLAUDE.md updated.

Verified: npm run check (typecheck 0 errors, 49/49 tests, lint clean of errors, format clean), npm run build (production minified), npm run knip (zero findings).

🤖 Generated with Claude Code

* PR 11/N (Stage 2): fix sync-hook missing await + consolidate handlers + explicit accountability + Promise lint rule

Stage 2 tasks #37, #38, #41 plus the @typescript-eslint/no-floating-promises rule (type-aware linting set up).

- translations.create missing await fixed by consolidating 9 action() registrations behind 3 for...of loops over event-name tuples (the SDK 17 signature accepts only a single event string, not an array).
- All 8 ItemsService instantiations now pass { schema, accountability: null } explicitly. Write ops in DirectusApiService pass { emitEvents: false } to prevent any future hook-driven write from recursively re-triggering the hook.
- New extensions/sync-hook/src/index.test.ts (12 tests) mocks defineHook + sync services and asserts handler registration, await semantics, and payload normalisation. The original missing-await bug would have failed this suite.
- @typescript-eslint/no-floating-promises set to warn (catches the bug class without breaking CI on the 23-site fire-and-forget baseline). no-misused-promises intentionally off (SDK 17 types action() as () => void; every async handler is a false positive).
- tsconfig.json includes vitest.config.ts so the project service resolves it. New Stage 2 task #43 to triage the 23 floating-promises baseline.

Verified: typecheck 0 errors, 61/61 tests, lint 0 errors / 122 warnings, production build clean.

🤖 Generated with Claude Code

* PR 12/N (Stage 2): typed Directus service constructors + fix latent resolveExportLanguages bug

Stage 2 task #39. Replaces any-typed ItemsService/FieldsService/logger params with proper types derived from @directus/types. New extensions/sync-hook/src/types/directus-services.ts.

Surfaced + fixed a real bug: BaseContentSynchronizationService.resolveExportLanguages was passing the ItemsService constructor where SynchronizationLanguagesService expects a DirectusApi instance. Any code path reaching it would have failed at runtime. Wraps ItemsService in DirectusApiService now; threads schema through.

Other typecheck consequences: fields: '*' -> fields: ['*'] (Query.fields is string[]), array indexing [0] -> destructure with ?? null default, e: any -> e: unknown with instanceof guards, test logger mock fleshed out to Pino Logger shape.

Stats: lint 122 -> 91 warnings (-31), no-explicit-any baseline 99 -> 68 (-31). Tests 61/61. Build clean.

🤖 Generated with Claude Code

* PR 13/N (Stage 2): polish bundle — hook README, common pkg description, sandbox note, husky nvm fix

Stage 2 tasks #36, #40, #42.

- extensions/sync-hook/README.md gets a prominent 'Installation requirements' section at top (MARKETPLACE_TRUST=all). Previously buried under Via Marketplace.
- extensions/common/package.json description fixed (was the create-directus-extension placeholder).
- CLAUDE.md Architecture section explains why the hook is intentionally non-sandboxed (Directus' sandbox only exposes log/sleep/request; the hook needs ItemsService and FieldsService).
- color: '#066fef' on defineModule left as-is (SDK 17 accepts it via ExtendedConfig but it's not in documented ModuleConfig; removing risks visual regression unverifiable outside admin).
- .husky/pre-commit sources nvm and runs nvm use before lint-staged so contributors with older default Node don't hit ESLint 10's util.styleText requirement. Falls through gracefully on systems without nvm.

Verified: 61/61 tests, lint clean of errors, format clean, production build clean.

🤖 Generated with Claude Code

* PR 14/N (Stage 2): triage floating-promise sites, fix real bug, promote rule to error

Stage 2 task #43. Cleared 22-site baseline + fixed one real bug + promoted no-floating-promises from warn to error.

- Real bug: deprecateLocalazyKeys was queuing async callbacks that didn't await LocalazyApiThrottleService.updateKey, so the queue resolved before the API call actually finished. Added the missing await.
- 21 intentional fire-and-forget sites marked with void + explanatory comments (analytics calls, Vue setup hydrations, throttle service queue kickoffs, onSaveSettings).
- Rule promoted to error so a new floating Promise blocks CI from here on.
- no-misused-promises stays off (SDK 17's action() typing still produces false positives).

Stats: lint warnings 91 -> 69 (-22). no-floating-promises baseline 22 -> 0. Tests 61/61.

🤖 Generated with Claude Code

* PR 15/N (Stage 2): bump @localazy/generic-connector-client 0.2 → 0.4 (drop-in)

Stage 2 task #31. 0.x major bump but the API surface we use (GenericConnectorClient constructor, Services.DIRECTUS, getOAuthAuthorizationUrl) is unchanged. Two consumer sites continue to work as-is.

Pre-existing @localazy/languages 0.1.11 vs 1.0.0 duplication unchanged — task #32 resolves it alongside the community language-mapping feature integration.

Verified: 61/61 tests, lint/format/typecheck clean, production build clean. Module bundle 384.7 -> 384.8 KB.

🤖 Generated with Claude Code

* PR 16/N (Stage 2): bump @localazy/languages 1 → 2 (drop-in)

Stage 2 task #32 dep-bump half. The community PR #21 language-mapping feature integration will follow as PR 17.

v2 public API surface unchanged for our usage (getLocalazyLanguages, findLocalazyLanguageByLocale, Language type, Locales enum). Language adds 'important' and 'bcp47' fields additively. Bundle 384.8 -> 395.2 KB (+10.4 KB from larger Locales enum + more language records).

Note: @localazy/api-client and @localazy/generic-connector-client still depend on @localazy/languages@^0.1.6 transitively. Those copies are isolated under each client's node_modules and don't leak; upstream clients would need to bump for full deduplication.

Verified: 61/61 tests, lint/format/typecheck clean, production build clean.

🤖 Generated with Claude Code

* PR 17/N (Stage 2): eliminate no-explicit-any baseline (68 → 0), promote rule to error

Stage 2 task #35. Comprehensive sweep across 30 files. Catch blocks bulk-replaced to unknown with internal normalisation in trackXxxError / addLocalazyError / addDirectusError. Record<string, any> -> Record<string, unknown>. Promise<any> -> Promise<T>/<unknown>. Typed structural overlays for predicate callbacks. Class-internal any properties typed via SDK / pino derived types.

Surfaced and documented a latent bug in synchronization-languages-service.ts: filter by l.enabled where Localazy's Language type has no such field — runtime is always undefined, filter is effectively inert. Preserved behavior with documented cast.

Bonus: axios dropped from module deps (no longer imported after addDirectusError signature widened).

Rule promoted: @typescript-eslint/no-explicit-any 'warn' -> 'error'.

Verified: 61/61 tests, 0 lint errors, typecheck clean, knip zero findings, production build clean.

🤖 Generated with Claude Code

* PR 18/N (Stage 2): integrate community PR #21 language-mapping feature (#40)

Adapts the language-mapping work from community PR #21 (DonkeyOatie) to the
repo's conventions (no `as any` / `as unknown`, typed Directus services).

Adds:
- `extensions/common/models/language-mapping.ts` — `LanguageMapping` types
- `extensions/common/services/language-mapping-service.ts` — service that
  resolves Directus ↔ Localazy codes via explicit mappings, falling back to the
  default `-` ↔ `_` swap. Includes `validateMappings` for the editor.
- `extensions/module/.../LanguageMappingsEditor.vue` — admin UI for managing
  the list, wired into `AdvancedSettingsForm.vue`.
- `language_mappings: string` field on the `Settings` model + Directus field
  definition + default `'[]'`.
- Tests for the service and adapter integration.

Wiring:
- `DirectusLocalazyAdapter` gains `initializeMappings`/`getMappingService`/
  `clearMappings` statics. The transform methods delegate to the mapping
  service when initialised; otherwise fall back to the prior behaviour.
- Hook side: `SynchronizationLanguagesService.resolveImportLanguages` /
  `resolveExportLanguages` call `initializeMappings(settings.language_mappings)`
  before transforming. `createLanguages` also routes the Localazy code through
  the transform so custom mappings win on the seeded Directus row, and is
  converted from a `forEach(async ...)` (which fire-and-forgets) to a proper
  `for...of` loop.
- Module side: `useHydrate.loadSettings` calls `initializeMappings` after the
  settings singleton loads, so any subsequent transform call in the admin uses
  the mappings.

Out of scope (PR #21 mixed these in but they're separate concerns; revisit in
follow-ups if needed): dynamic FK field resolution from relations, UUID-aware
item ID comparison, and `validateLanguageCode`/`isRecognizedByLocalazy`
helpers.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 19/N (Stage 2): fix UUID PK comparison + dynamic language FK resolution (#41)

Three real bugs in `use-directus-localazy-adapter.ts` that PR #21 also fixed
but bundled with the language-mapping feature. Splitting them out so each
fix is self-contained.

1. **UUID-aware item lookup.** `+i.id === +itemId` returns `NaN === NaN` for
   any UUID-keyed collection, which is always `false`. The previous code
   therefore silently skipped every translation upsert on UUID PKs. Switched
   to `String(i.id) === String(itemId)`.

2. **Dynamic language FK resolution.** The composable hardcoded
   `languages_code` as the FK column linking a translation row to its
   language. Real-world Directus installations often use a different name
   (e.g. `lang`, `language`, `lang_code`). Use `useRelationsStore` to look up
   the actual relation by `related_collection === settings.language_collection`,
   falling back to `languages_code` when not found. The Directus query now
   expands the language relation (`${field}.${fkField}.*`) so the FK column
   yields an object we can pull `settings.language_code_field` off; the new
   `extractLanguageCode` helper accepts both the expanded object and the bare
   string for back-compat.

3. **`forEach(async ...)` → `for...of`** in `upsertItemsFromSingleCollection`.
   Same fire-and-forget shape as the bugs fixed in PRs 11/14: the outer
   `forEach` returns before any inner upsert resolves, so the surrounding
   progress tracker advanced past work that hadn't actually completed and
   errors went unhandled.

Added a typed wrapper `useDirectusRelationsStore` matching the existing
`useDirectusCollectionsStore` pattern, and a unit test for `extractLanguageCode`
covering string FK, expanded object FK, non-default code-field name, and the
malformed/missing cases.

`upsertFromLocalazyContent` now takes `Settings` so the FK + language-code
field can flow down; threaded through the single caller in
`use-sync-container-actions.ts`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 20/N (Stage 3): use private-view title/icon props instead of hand-rolled slots (#42)

The 5 module views (Overview, Sync, ProjectSetup, AdvancedSettings, About)
each rebuilt private-view's title bar by hand — a `<template #title-outer:prepend>`
slot wrapping a `<v-button rounded disabled icon secondary>` plus a `<template #title>`
slot containing `<h1 class="type-title">`. The component already accepts `title` and
`icon` as props and renders them with the same icon-in-a-box treatment Directus uses
for its own modules.

Switching to props drops the duplication and brings these views in line with how
Directus' own admin renders module headers. Verified visually in dev mode against
all five routes.

Also drops:
- Sync.vue's `.title { display: none; @media (min-width: 1400px) { display: block } }`
  rule. It hid the title below 1400px width — a custom responsive that the prop-based
  rendering doesn't need (the title shows alongside action buttons without overlap at
  our typical viewport widths). If a layout issue surfaces at narrow viewports, we'll
  fix it differently (e.g. responsive action button arrangement) rather than hiding
  the title.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 21/N (Stage 3): split useHydrate into installer + per-singleton stores (#43)

The architectural pivot of Stage 3. Replaces the 442-line useHydrate composable
(which conflated install, heal, read, and "normalize on load" concerns behind a
module-level singleton pattern) with three focused pieces:

- **useLocalazyInstallerStore** — a Pinia store that runs once at boot,
  declaratively ensuring the Localazy collections + fields exist (creating with
  seed rows when missing, healing missing fields on existing collections via the
  correct `/fields/{collection}` route). Idempotent, no version tracking — schema
  is the source of truth.

- **createSingletonStore<T>** factory in `use-localazy-singleton.ts` —
  wraps the singleton-fetch pattern (gated on `installer.installed`) with
  `{ data, loading, error, save, reload }`. Defaults are merged at read time so
  consumers never see `null`. The factory is composed into three Pinia stores —
  `useLocalazySettingsStore`, `useLocalazyConfigStore`,
  `useLocalazyTransferSetupStore` — each a one-liner that pins the collection
  name + defaults shape.

- **useLocalazyConfigurationStatus** — pulls the `hasIncompleteConfiguration`
  computed out of the old useHydrate into its own focused composable.

Drops the **normalize-on-load** anti-pattern that silently wrote defaults back
to storage whenever the stored value happened to equal a default. Defaults now
live at the read site (the factory), never written.

Fixes a real bug pre-dating this PR: `createField` used to POST to
`/collections/{collection}/fields` — a route that doesn't exist in Directus 11
(verified by the "Route /collections/localazy_settings/fields doesn't exist."
error visible in dev mode on any existing install). The correct route is
`/fields/{collection}`. Means existing installs that missed the `language_mappings`
field from PR 18 (because field-healing was silently broken) will pick it up
on next boot under the new installer.

Consumer migrations:
- All 4 views (Overview, Sync, ProjectSetup, AdvancedSettings) replace
  `useHydrate()` with the new stores + the configuration-status composable.
- LoginButton/LogoutButton now read+write `useLocalazyConfigStore` directly,
  dropping the `localazyData` and `localazyDataCollection` prop-drilling that
  ran ProjectSetup → ProjectSetupForm → Login/LogoutButton. The form likewise
  reads `localazyData` from the store; its `v-model:localazy-data` and
  `localazyDataCollection` props are gone.
- `use-sync-container-init` and `use-sync-container-actions` read from the
  per-singleton stores instead of a locally-cloned `configuration` ref —
  Sync.vue no longer threads `contentTransferSetupCollection` / `contentTransferSetup`
  through `@upload` / `@download` / `@save-settings` handlers.
- `useHydrate` deleted.

Net diff: +502 / -714 lines.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 22/N (Stage 3): useSingletonForm composable; refactor AdvancedSettings + ProjectSetup (#44)

The watch-clone-diff-save pattern was duplicated in two views — settingsEdits ref,
deep watch reseating on source change, isEqual-based changesExist computed, save
handler with loading flag. Extract it as `useSingletonForm<T>(store)` returning
`{ edits, changesExist, save, loading }`.

The composable accepts any `useLocalazySingleton`-style store and:
  1. clones the persisted data into a working `edits` ref
  2. watches the store's data and reseats the working copy on every change
     (initial load, post-save reload, cross-tab edits)
  3. computes `changesExist` via lodash isEqual
  4. exposes a save() that pushes the edits through `store.save`
  5. proxies the store's loading flag so the button can disable mid-save

Notification stays at the call site — the text varies per page ("Settings saved"
vs whatever Project Setup eventually shows), and the form composable shouldn't
own that concern.

`AdvancedSettings.vue` and `ProjectSetup.vue` lose ~22 lines each: no more local
ref + watch + computed + try/finally save. The post-`useSingletonForm` shape is
genuinely tiny — these views are now ~60 lines total and structurally identical
except for which form component renders.

New `use-singleton-form.test.ts` covers seeding, dirty tracking, save dispatch,
external-update reseating, and loading proxy. 92 tests total, all passing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 23/N (Stage 3): DirectusModuleApi class + slim DirectusApi interface (#45)

Completes the cut started in PR 21. After the singleton CRUD and installer ops
were absorbed by the factory and the installer, what was left of `useDirectusApi`
(216 lines) was just the module-side implementation of the `DirectusApi`
contract the common services consume. Convert from composable to a service
class — `DirectusModuleApi` — to mirror `DirectusApiService` on the hook side.

Constructed at the call site:
```
const directusApi = new DirectusModuleApi(useApi(), useDirectusCollectionsStore());
```

Symmetric with the hook:
```
const directusApi = new DirectusApiService(ItemsService, schema);
```

The four module-side composables that previously passed `useDirectusApi()` to
common service constructors now build a `DirectusModuleApi` instance and pass
that. `use-directus-localazy-adapter.ts` does the same and calls the two
non-interface helpers (`updateDirectusItem`, `fetchDirectusItems`) directly on
the instance.

Interface slimmed: the historical `updateDirectusItem`, `upsertDirectusItem`,
`fetchDirectusSingletonItem`, and `createField` methods aren't called by any
common service — removed from the interface and from `DirectusApiService` on
the hook side too. Module-side `updateDirectusItem` and `fetchDirectusItems`
survive as class methods (used by the adapter) without polluting the shared
contract.

`extensions/module/src/composables/use-directus-api.ts` deleted (-216 lines).

Net diff: +139 / -268 across 8 files.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 24/N (Stage 3): composable audit pass (#46)

Applies the criteria agreed at the Stage 3 grill (Q7):
  - Owns reactive state? No → downgrade to utility or factory.
  - Called from one place and < 50 lines? Inline it.
  - Multiple responsibilities? Split.

Three moves:

**Inline `use-translatable-collections-content` into `use-translatable-collections`.**
The former was a 26-line pure wrapper around `useFieldsStore` and `useRelationsStore`,
adapted into a `DirectusDataModel`. Single call site (the latter), so the indirection
wasn't earning its keep. Construct the adapter inline at the one place that needs it.

**Inline `use-sync-container-init` into `Sync.vue`.**
~40 lines, single caller (Sync.vue), pure setup state (refs + one watch). The
historical separation made sense when `useHydrate` was 442 lines and `Sync.vue`
needed help, but now both files are reasonable and the indirection costs more
than it saves.

**Move `use-localazy-singleton.ts` → `stores/singleton-factory.ts`.**
The file's export was `createSingletonStore` — a factory that returns Pinia
setup functions, not a composable in the Vue sense. The misleading filename
+ directory was a holdover from when I first wrote it. Belongs in `stores/`
next to the singleton stores it produces.

After audit the composables directory holds 11 composables, all with either
reactive state, multiple consumers, or store-access wrapping. Each one earns
its `use*` prefix.

Net diff: +46 / -82 across 8 files. 92 tests still passing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 25/N (Stage 3): reactivity pattern standardization (#47)

Final Stage 3 PR. Applies the rule agreed at the Stage 3 grill (Q8):
  - Directus stores (anything reached via `useStores()`) → always through the
    typed wrappers in `use-directus-stores.ts`.
  - Our own Pinia stores → consume directly with `storeToRefs` for state refs.
  - Never direct-destructure state from a Pinia store (silently breaks
    reactivity).

Two new typed wrappers added:
  - `useDirectusFieldsStore` (5 call sites used the raw form)
  - `useDirectusNotificationsStore` (5 call sites used the raw form)

Sites migrated:
  - 5x notifications: AdvancedSettings.vue, ProjectSetup.vue,
    use-sync-container-actions.ts, LoginButton.vue, LogoutButton.vue
  - 5x fields: use-collections-organizer.ts, use-translatable-collections.ts,
    use-get-fields-for-translation-relation.ts, ProjectSetupForm.vue,
    localazy-installer-store.ts
  - Stray `useCollectionsStore` raw access in
    use-get-fields-for-translation-relation.ts replaced with
    `useDirectusCollectionsStore`.
  - Stray `useRelationsStore` raw access in use-translatable-collections.ts
    replaced with `useDirectusRelationsStore`.

After this PR, `useStores` is imported from the SDK in exactly one file —
`use-directus-stores.ts` — and never anywhere else in the module. Every
Directus store access is type-safe at the call site.

One typecheck fix bundled in: now that `useDirectusFieldsStore` types
`getFieldsForCollection` as sync `Field[]` (matching runtime), the
`DirectusDataModel` adapter inlined in PR 24 needed `async` qualifiers to
satisfy the interface's `Promise<Field[]>` (the hook-side implementation is
genuinely async, so the interface stays).

Net diff: +69 / -41 across 11 files. 92 tests still passing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 26/N (Stage 3+): useLocalazyBoot + defineModel adoption + sync-flow cleanups (#48)

Follow-up cleanups identified in the post-Stage-3 review pass.

**`useLocalazyBoot()` composable.** The same 6-line boot block repeated in
Overview, Sync, ProjectSetup, AdvancedSettings — installer.run().then(
hydrateLocalazyData(...)). Extract as a composable returning the relevant
refs plus an explicit `boot()` function. Callers wire it up with
`onBeforeMount(() => void boot())`, which makes the side effect explicit at
the lifecycle hook rather than firing implicitly during setup.

**`defineModel()` adoption (Vue 3.4+).** Replaces the manual
`computed get/set + emit + defineProps` pattern in 3 components:
  - `SyncOptionButtons.vue` (2 v-model bindings)
  - `AdvancedSettingsForm.vue` (1 v-model)
  - `ProjectSetupForm.vue` (1 v-model)
Drops ~7 lines per binding. The dropped emit declarations are real cleanup,
not stylistic — manual computeds were forwarding edits one indirection layer
deep with no behavior added on top.

**Two real bugs fixed in `onExport`:**
  - Used `ProgressTrackerId.PREPARING_IMPORT` for the export path. The enum
    value was defined but only ever used by this miswired code, so renamed
    in-place to `PREPARING_EXPORT`. No new enum needed.
  - Progress message said "Preparing Directus data for import" on the export
    flow. Fixed to "for export".

**Sync.vue: drop no-op `.flat()`** on `allTranslatableFields` computed. The
map function was returning single-element arrays just to flatten them — the
flatten was pure cargo culting. `map(c => ({...}))` is what was intended.

**Typo fix: `isTranlatableField` → `isTranslatableField`** in CollectionItem.vue
(local alias of FieldsUtilsService.isTranslatableField). Used in 6 spots in
the file.

Net diff: +58 / -112 across 10 files plus the new composable. 92 tests still
passing. Visually verified Overview + Sync in dev mode.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 27/N (Stage 3++): final review cleanup (#49)

Findings from one more pass through every page, component, and composable.

**Real bug: `TranslationStringsContent.vue` toggle was broken.** The hand-rolled
v-model had:

    function onToggle() {
      emits('update:shouldSynchronize', props.shouldSynchronize);
    }

The handler ignored the new value v-checkbox passed and re-emitted the
*current* prop, so the parent's synchronizeTranslationStrings ref never
flipped. Verified the bug existed (initial state `checked: true`, no toggle
on click) and the fix works (toggles cleanly between true/false). Replaced
the manual emit pattern with `defineModel`.

**Typos that had rooted themselves:**
  - `ConnectionOverview.vue`: CSS class `connection-overiew` → `connection-overview` (two usages, self-consistent typo)
  - `ConnectionLanguages.vue`: field `recoznigedInLocalazy` → `recognizedInLocalazy` (type + template + computed, 4 occurrences)

**`defineModel` adoption — caught up the stragglers PR 26 missed:**
  - `LanguageMappingsEditor.vue` — was still using manual modelValue prop + emit
  - `ProgressTrackerModal.vue` — defineModel for `showProgress`, drop the
    never-emitted `update:showProgress` from the emit declaration. Sync.vue
    updated to use `v-model:show-progress` (was a one-way `:show-progress` bind).

**Dead code removed:**
  - `ConnectionLanguages.vue` — commented-out `// const allLanguages = uniq(...)` line
  - `import-from-localazy-service.ts` — commented-out `// import { trackLocalazyError }` + dead else-branch in `loadFile` (token already guarded above)
  - `TranslationStringsContent.vue` — ~30 lines of SCSS for classes that don't exist in the template (looked copy-pasted from CollectionItem.vue)
  - `use-import-from-localazy.ts` — `loading` ref declared, returned, never used

**`Navigation.vue` — non-reactive refs.** `version` and `versionLabel` were
wrapped in `ref()` even though `packageJson.version` is a build-time constant
that never changes. Just plain constants.

**Pinia store ID consistency.** Most stores used camelCase IDs
(`'localazyStore'`, `'localazySettings'`); `progress-tracker-store` was the
odd kebab-case one out. Renamed to `'progressTrackerStore'`. No functional
effect — devtools labels only.

**`useExportToLocalazy(token)` hoisted out of `onExport`.** Was constructed
inside the event handler each call, throwing away the returned `loading`
ref. Lifted to setup with a top-level access-token computed.

Net diff: +30 / -91 across 11 files. 92 tests still passing. Verified the
toggle bug fix programmatically in dev mode.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 29/N: dev environment cleanup (sass, log levels, schema defaults, seed) (#51)

- Sass: `@import` -> `@use ... as *` in 9 SFC <style> blocks (Dart Sass deprecation).
- Sync-hook log noise: downgrade `logger.error('Localazy: Incomplete configuration')` to `logger.debug('not configured yet — skipping...')` in collection + translation-strings services. Was firing on every items.create / settings.create before Localazy is connected. Test updated.
- Schema defensiveness: explicit `default_value` on every `localazy_data` and `localazy_settings` field so the installer healing path produces predictable values.
- Dev seed: new `scripts/seed-dev-data.mjs` (articles + translations + languages); `scripts/dev.mjs` health-polls Directus after first bootstrap and invokes the seed.
- New `extensions/module/src/data/default-configuration.test.ts`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 30/N (Stage 4): incremental download sync (#52)

* PR 30/N (Stage 4): incremental download sync

- New `localazy_sync_state` singleton holding the per-(language, Localazy key id)
  event cursor, wired through the installer alongside the other singletons.
- Cursor utilities (`sync-cursor.ts`) with serde, project-match check, filter,
  merge-by-max, and in-place recording, all unit-tested.
- `import-from-localazy-service` now fetches with `event: true` and accepts an
  optional post-fetch `filterKeysForLanguage` hook; the parser stays oblivious.
- Adapter and translation-strings composable report back written triples via an
  `onWritten` callback (PATCH-then-mark — only marked after the write resolves).
- Orchestrator runs `onImport(mode)`: loads the cursor, auto-invalidates on
  project change, filters by event, throttles cursor flushes (every 10% / min
  50 keys), final flush at end. Decision-19 progress messages emitted to the
  modal. "Already up to date" short-circuits the write phase.
- New split button: "Import to Directus" runs incremental by default; the
  dropdown adds "Full Sync" which runs the same path with an empty in-memory
  cursor (merge-on-persist keeps prior entries correct).
- Tests: 101 -> 143 (+42).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 30 review fixes: per-language localazyKeys + try/finally final flush

- Translation-string cursor was effectively broken: the parser stored a single
  `localazyKey` on each block, overwritten per language, so cursor entries used
  the wrong-language id and never matched on the next sync. Replaced
  `localazyKey: Key` with `localazyKeys: Record<string, Key>` and updated
  parsers (common + module copies), the upsert-side cursor emitter, and the
  sync-hook deprecation path (which previously deprecated only the last
  language's id — pre-existing bug, incidentally fixed). Tests updated.
- `use-sync-container-actions.ts`: wrapped `upsertFromLocalazyContent` in a
  `try { writes } finally { await persistCursor(inMemoryCursor) }` so the final
  flush contract is literal — accumulated triples persist even if a writer
  throws mid-sync.

143 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 30 follow-up: fix initial-load race that showed "Not connected" on hard refresh

The singleton factory's installer-driven first reload is fire-and-forget
(`void reload()` inside the watcher). `useLocalazyBoot.boot()` awaits the
installer and then immediately calls `hydrateLocalazyData`, which reads
`localazyData.value` — at which point the watcher's reload may still be in
flight. The hydrate then sees stale defaults (empty `access_token`) and the
Overview renders "Not connected to Localazy" until the user navigates away
and back (by which time the watcher's reload has populated the store).

Pre-existing on `next`; surfaced during PR 30 manual verification.

Fix: expose a `firstLoad` promise from the singleton factory that resolves
once the installer-driven first reload settles (success or error). `boot()`
awaits `configStore.firstLoad` between `installer.run()` and
`hydrateLocalazyData`. Two-file change; 143 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 31/N (Stage 4): incremental upload sync (#53)

* PR 31/N (Stage 4): incremental upload sync

User-clicked Export now defaults to incremental: items are filtered against a
per-item content-hash cursor, only the changed items are pushed, and the cursor
is rebuilt on Full Upload. Mirrors PR 30's download-side machinery — same
singleton, same throttled-flush cadence, same merge-on-persist for concurrency,
same v-menu split-button UI shape.

Decisions captured in the upload-side grilling notes
(`Work/Localazy/Development/Directus extension 2.0 release - progress.md`):

- Cursor: per-item content hashes `{ [collection]: { [itemId]: hex16 } }` on the
  existing `localazy_sync_state` singleton (new `uploaded_hashes` text JSON
  field). Hash subsumes three concerns in one mechanism: enabled-fields
  changes, `upload_existing_translations` toggles, and non-translatable-field
  bumps to `date_updated`.
- Hash mechanics: canonicalised (deep-sort keys, undefined as absent, preserve
  null and whitespace) over the "what would be uploaded right now" KV payload;
  SHA-256 via Web Crypto, truncated to 16 hex chars (64-bit collision space).
  Computed inside `resolveContentForCollection`, attached per-item alongside
  the fetched items.
- Filter point: after fetch, before parse. Item where current hash === stored
  hash is skipped entirely.
- Marking granularity: per-item-after-all-chunks-succeed. Orchestrator tracks
  chunk membership (`Map<itemId, Set<chunkId>>`) + chunk successes; an item is
  marked done iff its membership is a subset of the successful chunks.
- Persistence cadence: throttled to `max(50, ceil(totalItems / 10))` items,
  plus a final flush wrapped in `try { writes } finally { persist }` so
  accumulated state survives a writer error.
- Concurrency: in-tab disable + merge-on-persist. Soft DB lock deferred
  (tracked follow-up).
- Cursor auto-invalidation: shared `cursor_project_id` mismatch wipes the
  in-memory cursor (same mechanism as download).
- Translation strings: always full re-push, no cursor entries.
- Schema / field-level / project-config changes: all naturally reflected in
  the hash. No forced full upload from any non-explicit signal.
- UI: existing Export button stays default-incremental; "Full Upload" lives
  under a dropdown next to it, parallel to the Import/Full Sync pattern.
- Progress messages mirror download: "Preparing items for upload..." /
  "Found N changed items..." / "Pushing chunks (i/N)" / "Uploaded N items in
  T.Ts." / "All items already uploaded — nothing to push" / "Full upload —
  re-pushing everything".

200 tests pass (143 → 200, +57 new — cursor utilities + summarize-upload-content).
Manual UI verification is left to the reviewer (port 8055 is occupied by the
main session's dev server, so the agent did not run `npm run dev`).

Out of scope (tracked follow-ups):
- Hook-side cursor updates from `items.update` (stretch goal not taken)
- Soft DB lock once automated import lands
- Orphan deletion handling shared with download

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 31 review fixes: remove duplicate end-of-export message + smarter final summary

Three small polish items surfaced by the in-session review of PR 31:

1. `use-export-to-localazy.ts` was emitting `EXPORT_FINISHED` ("Export finished"
   / "Nothing to export from selected sources") after every export. The new
   orchestrator in `use-sync-container-actions.ts` also emits its own
   `UPLOAD_FINISHED` summary, so the user saw two end-of-flow messages stacked
   in the progress modal. The orchestrator's message has more context (count +
   elapsed time) and already covers the empty path via `UPLOAD_UP_TO_DATE`, so
   the inner duplicate is removed. (`isEmpty` import + the now-unused
   `nothingToExport` local cleaned up alongside.)
2. The orchestrator's final summary used `writtenSinceStart` (tracked-item
   uploads) for the item count. When only translation strings flow through
   (no items pass the cursor filter), `writtenSinceStart` stays at 0 and the
   message read "Uploaded 0 items in 1.2s." — technically correct but
   misleading. Now: "Uploaded N items in T.Ts." when items > 0, otherwise
   "Upload completed in T.Ts." — honest without over-promising what we can
   verify per-item.
3. Doc comment in `sync-state.ts` said `last_sync_at` was "ISO timestamp of
   the last successful download sync". It's shared between upload and
   download flows now (both call sites bump it).

200 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 31 second-review fixes: reset progress tracker at sync start + cursor invalidation consistency

Two small items surfaced by the second-pass review of PR 31:

1. The progress tracker (`useProgressTrackerStore`) was only reset via
   `onFinishAction` (the Done-button handler). Any other dismissal — clicking
   Export/Import again while the modal is open, page navigation, etc. — left
   stale messages from the previous run in the store. The next sync would
   `addProgressMessage(...)` on top of them, producing duplicate "(en) Export
   1/1 content chunks" rows and similar artifacts in the modal. Fix:
   `resetProgressTracker()` at the start of both `onExport` and `onImport`,
   so each run always starts the modal fresh. Manual UI verification in
   Chrome confirms the modal now shows only the current run's messages.

2. Upload cursor invalidation used an inline `cursor_project_id !== ...`
   check, while the download path used the shared `cursorMatchesProject`
   helper from `sync-cursor.ts`. The two diverge on the edge case where the
   stored `cursor_project_id` is empty (e.g. install upgraded from a build
   without that field): the helper treats it as "first sync, accept the
   cursor", the inline check treats it as a mismatch. Aligning to the helper
   keeps the two flows consistent and is the design's intended semantic.

200 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 28/N: split CONTRIBUTING.md out of README; fix module README typo (#50)

Findings from the README review pass.

**CONTRIBUTING.md (new).** GitHub auto-shows this file on PR/issue creation —
that's the idiomatic place for contributor onboarding. Moves setup,
prerequisites, dev loop, reset, scripts, and CI expectations out of the root
README. Adds the things the previous root README was missing:
  - Project layout — explains the three-workspace monorepo (module / sync-hook
    / common) and why `common` isn't published.
  - Full scripts table including `check` / `check:fix` (the aggregate that
    mirrors CI), `typecheck`, `format` / `format:fix`, `test` / `test:watch` /
    `test:coverage`, `knip`. The previous README listed only `lint`, leaving
    tests effectively invisible to new contributors.
  - CI expectations — what runs on every PR.

**README.md (slim).** Cut from 84 to ~38 lines. Now scoped to "what is this
monorepo" + ecosystem links, with a pointer to CONTRIBUTING.md for the
contributor stuff. Doesn't repeat content that lives in either extension's
own README.

**Module README typo.** Line 13 was an empty H1 — `# 📦` with no title text.
The sync-hook README got this right (`# 📦 Directus Extension Localazy
Automation`). Fixed to `# 📦 Directus Extension Localazy`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 32/N (Stage 5): convert sync-hook to bundle (#54)

* PR 32/N (Stage 5): convert sync-hook to bundle (foundation for automated import)

Restructures @localazy/directus-extension-localazy-automation from a Directus
`hook` extension into a Directus `bundle` extension. Adds a minimal `endpoint`
child alongside the existing hook so the module-side Automation page (coming
in a follow-up PR) can ping `GET /localazy-automation/status` to detect that
the server-side bundle is installed.

Zero behavioural change to the hook itself: the existing defineHook code is
moved verbatim from `src/index.ts` to `src/hook/index.ts` along with all of
its `services/`, `composables/`, `functions/`, and `types/` subtrees. Common
imports (`../../../common/...`) gained one additional `../` because the
files now live one directory deeper. All 9 lifecycle action() registrations
and their handlers are byte-identical to before.

New endpoint child (`src/endpoint/index.ts`, under 15 LOC) exposes a single
public route `GET /localazy-automation/status` returning
`{installed: true, version: <package.json version>}`. The URL prefix
`/localazy-automation` is derived from the endpoint child's `name` field in
package.json — verified against `@directus/api`'s extension manager
(`registerEndpoint` uses `endpointRouter.use('/${name}', scopedRouter)`).

This is the start of "Stage 5". Subsequent PRs (B-G) will lift the
synchronisation orchestrator into common, add lock fields to
`localazy_sync_state`, add a webhook handler with HMAC middleware on the
endpoint side, and add the module-side Automation + Activity pages.

Verification findings (recorded before coding, per the bundle-doc memory):
- Public bundle docs at directus.io are thin — confirmed package.json shape
  but said nothing about endpoint URL routing or hook→bundle migration.
- SDK source (`@directus/extensions-sdk/dist/cli/commands/helpers/generate-bundle-entrypoint.js`)
  emits `{name, config}` pairs per API extension type; hook + endpoint
  coexist cleanly.
- `@directus/api/dist/extensions/manager.js:registerEndpoint` mounts the
  scoped router at `/${endpoint.name}` on the extension manager's root
  router, which `app.js` `app.use()`s at app root. So URL = `/<child-name>/<route>`.
- No authoritative migration story found for hook→bundle on the same npm
  name. Conservative recommendation: restart Directus after upgrading.

Build + manual verification: `npm run build` produces both `dist/api.js`
(containing hook + endpoint child configs) and `dist/app.js` (empty arrays —
correct, no app children). `npm run dev` boots Directus with the bundle
loaded; `curl /localazy-automation/status` returns HTTP 200 with the
expected JSON body.

**Note for upgraders**: A Directus restart is recommended after upgrading
to `@localazy/directus-extension-localazy-automation@1.1.0` because the
extension type changed from `hook` to `bundle`. The Directus extension
loader's hot-reload story across type changes is undocumented; a restart
is the safe path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 32 review fixes: eliminate as-unknown-as double-cast + update knip + CLAUDE.md

Three follow-up items from the code review on PR 32:

1. `endpoint/index.test.ts` used the `as unknown as TargetType` double-cast
   that CLAUDE.md "Coding conventions" explicitly forbids: prefer a single
   targeted cast (or no cast at all) over the `as unknown as` escape hatch.
   Refactor: extract `registerEndpoint` as a named export in `endpoint/index.ts`
   typed against a structural `MinimalRouter` (only the `get` shape we use).
   `defineEndpoint` accepts it because Express' `Router` is assignable to
   `MinimalRouter`. The test imports `registerEndpoint` directly and calls
   it with the fake router — zero casts in the test body, zero `as unknown as`
   anywhere in the new code. The pre-existing `as unknown as` site in
   `hook/index.test.ts` is untouched (the PR only moved that file; the
   pattern there predates this branch).

2. `knip.json` still listed `extensions/sync-hook/src/index.ts` as the
   workspace entry after PR 32 moved that file to `src/hook/index.ts` and
   added `src/endpoint/index.ts`. `npm run knip` would have reported every
   file under both children as unreachable. Update the entry array to
   `["src/hook/index.ts", "src/endpoint/index.ts"]`. Verified: only the
   pre-existing unrelated `scripts/seed-dev-data.mjs` finding remains.

3. Root `CLAUDE.md` "What this repo is" table still described
   `extensions/common/` as inlined into `dist/index.js` via `../../common/...`,
   which is correct for the module but not for sync-hook after the bundling.
   Replace with explicit per-extension paths: module → `../../common/...` →
   `dist/index.js`; sync-hook bundle children → `../../../../common/...` →
   `dist/api.js` (with `dist/app.js` empty because both children are
   server-side).

201 tests still pass. `npm run check` clean. `npm run knip` clean apart from
the pre-existing finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 33/N (Stage 5): lift incremental-import orchestrator to common (#55)

* PR 33/N (Stage 5): lift incremental-import orchestrator to common

Move the `onImport(mode)` flow out of the module's `use-sync-container-actions`
composable and into a shared `extensions/common/services/orchestrator/` package.
This unblocks PR F (webhook handler) reusing the exact same orchestrator
server-side without duplication.

Port interfaces (cursor-store / content-fetcher / progress-sink / fk-resolver /
error-sink) live in `ports.ts`. The orchestrator (`runIncrementalImport`) reads
top-to-bottom like a recipe; every side effect flows through one of the ports.
The collection + translation-string upsert step also moves to common
(`upsert-localazy-content.ts`); module-side concerns (Pinia stores, axios) are
isolated in `extensions/module/src/services/orchestrator-adapters.ts`.

Behavioural identity: every cursor write, progress message, persist cadence,
analytics call is preserved. Confirmed by 9 new orchestrator tests (happy path,
up-to-date, project-id invalidation, full-sync mode, aborted fetch, throttled
flush, error-in-upsert, analytics fired/skipped) plus the unchanged module-side
test suite. Tests 201 → 210 (net +9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 33 review fixes: stale docstrings + analytics-languages bug

Three follow-ups from the code review on PR 33:

1. `extensions/common/interfaces/directus-api.ts` JSDoc still listed
   `updateDirectusItem` as a "historical member that was dropped" — but this
   PR adds it back to the interface (`upsert-localazy-content.ts` in
   common now calls it). Drop `updateDirectusItem` from the dropped-list
   so the comment matches the interface body two lines below. Also remove
   the trailing "Module-side helpers ... live on DirectusModuleApi" clause
   since after the orchestrator lift there are no longer any module-side
   helpers outside the interface.

2. `extensions/module/src/services/directus-module-api.ts` had a stale
   class-level paragraph: "Beyond the interface this class also exposes
   updateDirectusItem and fetchDirectusItems, used by
   use-directus-localazy-adapter.ts to write to translation collections.
   Those aren't on the interface because no common service calls them."
   Every clause is now wrong: the adapter file is deleted, both methods are
   on the interface, and the common-side orchestrator does call them.
   Delete the whole paragraph; the remaining "Construction:" guidance
   stays useful as-is.

3. Pre-existing analytics bug surfaced by the PR 33 review:
   `Object.keys(importLanguages)` on a `DirectusLocalazyLanguage[]` returns
   stringified array indices `["0","1","2"]`, not the language codes the
   analytics payload's `languages: string[]` field expects. Pre-dated PR 33
   (it was preserved verbatim in the lift for behavioural identity, then
   surfaced when a reviewer noticed). Replace with
   `importLanguages.map((l) => l.directusForm)` — the Directus-side
   language code, matching the per-language synchronization key that
   `resolveImportLanguages` produces.

210 tests still pass. `npm run check` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 34/N (Stage 5): advisory sync lock + heartbeat + dirty-bit re-fire (#56)

* PR 34/N (Stage 5): symmetric advisory lock + heartbeat + dirty-bit re-fire

Adds a CAS-style advisory lock around the incremental-import orchestrator so
two concurrent Import attempts (today UI clicks, tomorrow webhook callbacks)
can't both run the sync at once. The losing contender sets a dirty bit; the
holder re-fires once on release, bounded by the cursor.

Lock persisted on the existing `localazy_sync_state` singleton via 7 new
fields (sync_in_progress, sync_started_at, sync_initiator, sync_pending,
sync_items_processed, sync_last_heartbeat_at, acquired_token) and the
installer's heal path picks them up automatically on upgrade. A 30 s heartbeat
keeps `last_heartbeat_at` fresh; staleness is heartbeat > 5 min or
started_at > 2 h (the hard ceiling defends the zombie case where the
heartbeat keeps firing but the run never finishes).

New `LockStore` port + module-side adapter follow the same pattern as the
existing `CursorStore` lift — orchestrator stays pure, side effects live in
the module's Pinia adapter. PR F's webhook handler will reuse the same port
through a server-side adapter.

UI surfaces: Import button disables (with tooltip) when the lock is live and
not stale; `runIncrementalImport` returning `{ status: 'skipped' }` shows a
Directus toast so the user gets feedback even if they bypassed the disabled
state; AdvancedSettings grows a hidden-by-default "Operator tools" section
with a Clear stuck sync button that only appears once the lock has been held
> 5 min.

Tests: 210 → 220. Ten new lock tests cover happy path, initiator labelling,
live-lock skip + dirty bit re-fire, stale-by-heartbeat takeover, stale-by-
ceiling takeover, CAS race loss, re-fire bound (no infinite loop), heartbeat
interval (fake timers), items-processed counter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 34 review fixes: re-fire isolation, markPending preservation, time-reactive Vue computeds, doc accuracy

Eleven fixes from the in-session code review on PR 34:

1. Re-fire in `finally` no longer swallows the original run's successful outcome.
   The recursive `runIncrementalImport(adapters, params)` from the dirty-bit
   drain is now wrapped in its own try/catch; errors surface via `errorSink`
   and don't propagate.
2. `buildLockStore.acquire` no longer writes `sync_pending: false`. We preserve
   whatever's on disk so a concurrent contender's `markPending()` between this
   run's top-of-call read and our acquire isn't stomped. Worst case: a leftover
   bit from a prior run triggers one cursor-bounded no-op re-fire.
3. `buildLockStore` JSDoc rewritten: acquire's atomic portion is "write -> verify",
   not "read -> write -> verify" (the orchestrator does the upfront read). Documents
   the best-effort-over-HTTP race window and why advisory + token-gated state
   makes it safe. `acquired_token` typo fixed.
4. `generateToken` JSDoc no longer claims UUID v4 — the fallback isn't one, and
   the token doesn't need UUID semantics anyway.
5. New `useNow()` composable drives a 30s-tick reactive `now` ref for the
   `Date.now()`-inside-computed wall-clock checks. `syncInProgress` (`SyncActionButtons.vue`)
   and `syncLookStuck` (`AdvancedSettings.vue`) now re-evaluate across the 5-min /
   2-h thresholds in observer tabs.
6. PROGRESS_ID_MAP comment example fixed: orchestrator emits `'fetching-translations'`,
   not `'sync-mode-header'` (which the composable emits before the orchestrator runs).
7. `makeInMemoryLockStore` JSDoc no longer references a non-existent `nowOverride`
   injection mechanism. Describes the real hooks: `mutate` / `set` / `setBeforeVerifyReadHook`.
8. `assertRan` JSDoc corrected — it throws a plain `Error`, not via `expect`.
9. `extensions/module/src/data/fields/sync-state/create.ts` file-level docstring
   extended with a paragraph naming the 7 new advisory-lock fields and pointing
   at the orchestrator + lock-constants files for semantics.
10. Pre-existing unrelated lint warning in translation-strings-service.ts left
    untouched (separate cleanup).

220+ tests still pass. `npm run check` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 34 review fixes (round 2): align test fake with production acquire + add markPending-survives-acquire test

Two related changes addressing a second-pass review finding:

1. The in-memory `makeInMemoryLockStore` fake's `acquire` body wrote `pending: false`
   on every acquire — matching the OLD production behaviour. Commit cfe8c42 changed
   production to preserve the on-disk `pending` value through acquire, but the fake
   was missed. After this commit the fake mirrors production: it spreads existing
   state and only overwrites the fields acquire actually changes.

2. Added a new test exercising the markPending-survives-acquire scenario: pre-state
   has `pending: true, in_progress: false` (simulating a contender's markPending
   between prior run's release-clear and our acquire). The test asserts that the
   orchestrator's run triggers two acquires (initial + re-fire) and the bit is
   cleared at the end. With the old stomping behaviour this test would have failed
   — it now correctly exercises the contract fix 2 of cfe8c42 introduced.

221 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 35/N (Stage 5): localazy_sync_log collection + Activity page (#57)

* PR 35/N (Stage 5): localazy_sync_log collection + Activity page

Add a new `localazy_sync_log` collection (row-per-session, retained at 100 most-recent)
and the Activity page (list + detail) so users can review past sync runs after the fact.
The orchestrator wires a fire-and-forget `SyncLogWriter` port: opens a session right
after lock acquire, emits milestone-only entries (started, fetched, per-collection,
finished), and finalises in the same `finally` as lock release (with `status: 'failed'`
when the run throws). Adds an `activity_logs_sort` JSON field on `localazy_settings`
for per-tab sort persistence and a one-line "Last sync" banner on the Sync page that
links back to the most-recent session.

Why: PR D of the automated-import plan. Sets up the surface PR E (Automation page) and
PR F (webhook handler) will write into; gives users retroactive visibility for any sync
trigger source without depending on the live progress modal.

Test coverage: 221 → 261 (+40). New tests cover the SyncLogWriter adapter
(start/append/finish + retention trim past 100), the orchestrator's milestone entries
(happy path, full-sync, partial/error, up-to-date, aborted, no-writer-wired, skipped-no-row),
and the Activity composable's pure helpers (tab routing, filter, sort, paginate, prefs
parse/serialize).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PR 35 review fixes: appendEntry race, FK ON DELETE, lock-hold, doc accuracy, dead code

Thirteen fixes from the in-session code review on PR 35:

1. `appendEntry` race fixed via per-session promise chain in createSyncLogWriter.
   Multiple fire-and-forget calls from the orchestrator's milestone callbacks now
   serialize per session, so no entry is silently dropped. New test exercises the
   race scenario directly.
2. `initiator_user` FK now declares `on_delete: 'SET NULL'`. Previously deleting a
   directus_users row that any retained sync_log session referenced would fail.
3. Lock release reordered before log finalisation in the orchestrator's finally.
   The trim's HTTP round-trips no longer extend lock duration.
4. Date filter switched to `Date.UTC(...)` for consistency with the UTC ISO
   timestamps in `session.started_at`. Fixes TZ-offset boundary bug for non-UTC users.
5. `status: 'aborted'` added to the model JSDoc enum and `StatusLabel.vue`'s two
   switch blocks. Aborted rows now render with a proper styled badge.
6. `generateSessionId` JSDoc no longer claims RFC 4122 v4 - the fallback isn't one
   and only uniqueness is required for a primary key.
7. Five inaccurate docstrings rewritten: store header now lists real exports,
  …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant