PR 3/N (2.0 stack): Vitest scaffold + utility and hook service tests - #25
Merged
Conversation
…ice. - Add vitest@^4 as root devDep and vitest.config.ts (Node env, *.test.ts under extensions/). - New scripts: `npm run test` (CI) and `npm run test:watch` (local). - qa.yml gains a Test step between Format check and Build. Tests: - extensions/common/utilities/sleep.test.ts (1 test, fake timers). - extensions/common/utilities/merge-with-arrays.test.ts (4 tests: array concat, object merge, nested arrays, untouched keys). - extensions/common/utilities/enabled-fields-service.test.ts (4 tests: valid JSON, garbage input, serialization, non-array input). - extensions/common/utilities/localazy-payment-status.test.ts (9 tests: over-limit / no-plugin-access / combined gate, including the off-by-one at the exact limit). - extensions/sync-hook/.../translation-strings-synchronization-service.test.ts (6 tests using vi.spyOn on BaseContentSynchronizationService.prototype to isolate orchestration logic — happy path, missing collections, disabled sync, empty content, deprecation gate, deprecation key-filtering). Total: 24 tests, 261ms. All pass. Demonstrates the spy-on-prototype mocking pattern future hook tests can copy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…casts. Inspired by the community test suite in #21 (DonkeyOatie). Adapted the test ideas, kept our own structure, and tightened typing so no test file uses `as unknown as` or `as any` (per the new CLAUDE.md coding-conventions rule on PR #24). Changes: - `localazy-payment-status.test.ts`: build full Project + Organization mocks via typed helpers using the actual `@localazy/api-client` types, no casts. Replace the `as unknown as Project` double-cast and the missing-organization edge case (`organization` is required by the Project type). - `enabled-fields-service.test.ts`: fix the wrong fixture shape (the type is `{ fields: string[] }`, not `{ field: string }`). Add cases for empty JSON array, malformed JSON, empty string, plain-object input via a parametrised non-array table. Add the round-trip test (prepare -> parse) for data-integrity guarantee. - `merge-with-arrays.test.ts`: add cases for null source, undefined source, empty target object, arrays of objects, deep nesting, and order preservation. Organise into `array values` / `object values` / `edge cases` describe blocks. - `localazy-api-throttle-service.test.ts` (new): `vi.hoisted` + `vi.mock` for the underlying `@localazy/api-client` and the `sleep` utility, with each delegation method tested using its real request type (`ImportJsonRequest`, `FileListKeysRequest`, etc.) — no `as any` on the option arguments. Adds coverage for token refresh (a new client is constructed each call) and for queue ordering / per-call error isolation. Total goes from 24 to 49 tests across 6 files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
elisiondan
added a commit
that referenced
this pull request
May 12, 2026
…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
added a commit
that referenced
this pull request
May 12, 2026
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
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,
…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Vitest at the workspace root and a focused first wave of tests. No source-code changes. CI now gates on tests too.
What's tested
extensions/common/utilities/sleep.test.tsextensions/common/utilities/merge-with-arrays.test.tsextensions/common/utilities/enabled-fields-service.test.ts[], serialization, non-array input.extensions/common/utilities/localazy-payment-status.test.tsshouldDisableSyncOperations.extensions/sync-hook/.../translation-strings-synchronization-service.test.tsvi.spyOn(BaseContentSynchronizationService.prototype, ...)mocks the orchestration boundaries. Covers: missing-collections early-return, payment-status abort, happy-path export, no-content skip, deprecation gate disabled, deprecation key-filtering.Total: 24 tests, 261 ms.
Why these specifically
Per the planning decisions:
translation-strings-synchronization-service.test.tsis the template future tests can copy (PR 4-7 will add more as deps shift).Mocking pattern for sync services (notes for reviewers)
BaseContentSynchronizationServiceis the orchestration spine — it talks to Localazy's API, Directus'ItemsService, the async queue, etc. The subclass (TranslationStringsSynchronizationService) wires those primitives into specific flows. Tests stub the protected base-class methods viavi.spyOn(BaseContentSynchronizationService.prototype, '...')so the subclass flow can be exercised without booting Directus or hitting Localazy. The subclass's privatefetchTranslationStringsis stubbed viaObject.getPrototypeOf(instance)for the happy-path test where its return value drives the rest of the flow.CI changes
qa.ymlnow runs: lint → format → test → build (production). Same workflow runs on PRs to bothmainandnext.Test plan
npm run testreturns 24/24 passing in ~300ms.npm run lintandnpm run formatstay clean over the test files.npm run buildsucceeds — test files are not bundled into the published extension (Rollup picks up onlysrc/index.tsentry points).Out of scope
collection-content-synchronization-service— the mocking pattern is established; pull it forward when bandwidth allows or alongside the Stage 2 refactor.🤖 Generated with Claude Code