Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
172 KiB
Scratchpad — App-Wide Search
Rolling notes, decisions, links, and gotchas. Append, don't rewrite history.
Decisions (with rationale)
-
2026-05-13 — Single global index table
app_search_indexrather than per-tabletsvectorcolumns. Why: one query searches everything; uniform ranking; one GIN index to maintain; lets us denormalize ACL columns alongside content. Trade-off: re-index needed on permission changes — accepted. -
2026-05-13 — Postgres FTS + pg_trgm, not Meilisearch/Typesense. Why: no new infra; respects Citus tenant sharding natively; simpler ACL story. Can graduate later if relevance becomes the bottleneck — the indexer interface won't change.
-
2026-05-13 — MSP portal only for v1. Client portal deferred to v2. Why: tighter ACL surface area; the same index table can be queried from client portal later with a stricter filter.
-
2026-05-13 — CE codebase, EE inherits. No edition-conditional code at v1. Why: EE is a superset of CE; new EE entities (extensions, etc.) can register their own indexers later without touching the core.
-
2026-05-13 — No PostHog feature flag. Why: user choice. Implies the test suite must be the gate; ACL leakage tests in particular must be exhaustive.
-
2026-05-13 — Denormalized ACL columns on the index (
visible_to_user_ids,visible_to_roles,is_internal_only,is_private,client_scope_id,required_permission). Why: filtering in SQL is the only way pagination/ranking stay correct without massive over-fetch. Cost: ACL changes require re-index — handled by the existing event-driven indexer. -
2026-05-13 — Two-layer ACL (denormalized SQL filter + record-level final pass). Why: defence in depth; record-level pass catches drift bugs and is logged as
search.acl_drifttelemetry. -
2026-05-13 — 64 KB body cap. Why: FTS quality plateaus past this; documents and long comment threads would bloat the index without recall benefit.
-
2026-05-13 — Time-decay ranking
exp(-age_days / 90), floor 0.05. Why: MSP users almost always want recent records; purets_rank_cdignores recency. Constants are best-guess; revisit with telemetry. -
2026-05-13 — Reuse existing
public.process_large_lexemes()Postgres function as the body cleanser (strips base64 data URIs, caps to 500 KB) beforeto_tsvector. Why: it already exists and was written for exactly this hazard.
Recon findings
Sidebar / nav
- Main MSP sidebar:
server/src/components/layout/Sidebar.tsx - Sub-components:
SidebarMenuItem.tsx,SidebarSubMenuItem.tsx,SidebarBottomMenuItem.tsx cmdk@^1.0.4is already inpackage.jsonbut no command palette is rendered yet.- i18n namespace
msp/core(server/public/locales/en/msp/core.json); addsearch.*keys.
Entity tables (CE)
| Entity | Table | PK | Tenant in PK |
|---|---|---|---|
| Client | clients |
client_id |
yes |
| Contact | contacts |
contact_name_id |
yes |
| User | users |
user_id (+ email) |
yes |
| Ticket | tickets |
ticket_id |
yes |
| Ticket comment | comments |
comment_id |
yes — is_internal boolean present |
| Project | projects |
project_id |
yes |
| Project phase | project_phases |
phase_id |
yes |
| Project task | project_tasks |
task_id |
yes |
| Project task comment | project_task_comments |
task_comment_id |
yes — body is BlockNote JSON in note + markdown_content |
| Asset | assets |
asset_id |
yes |
| Invoice | invoices |
invoice_id |
yes |
| Invoice item | invoice_items |
item_id |
yes |
| Invoice annotation | invoice_annotations |
annotation_id |
yes |
| Contract | contracts |
plan_id |
yes |
| Contract line | contract_lines |
(composite) | yes |
| Client contract | client_contracts |
contract_id |
yes |
| Document | documents |
document_id |
yes — content is BlockNote JSON |
| KB article | kb_articles |
article_id |
yes — FK to documents |
| Service catalog | service_catalog |
service_id |
yes |
| Service request submission | service_request_submissions |
submission_id |
yes — submitted_payload JSONB |
| Service request definition | service_request_definitions |
definition_id |
yes |
| Workflow task | workflow_tasks |
task_id (string) |
NOT in PK — verify |
| Interaction | interactions |
interaction_id |
yes |
| Schedule entry | schedule_entries |
entry_id |
yes |
| Time entry | time_entries |
entry_id |
yes |
| Board | boards |
channel_id |
yes (renamed from channels) |
| Category | categories |
category_id |
yes |
| Tag | tags |
tag_id |
yes |
TODO — verify workflow_tasks distribution column. If it's not distributed by tenant, joining/upserting from the indexer needs extra care.
Existing FTS code
- CE migrations: zero
tsvectorcolumns or GIN indexes today. - EE migrations: tsvector indexes already exist on
tickets.title,comments.note,documents.content(inee/server/migrations/202410291100_create_ai_schema.cjs) — these are for AI/chat features, not to be confused with the newapp_search_index. public.process_large_lexemes()function exists (added in20260302031500_strip_data_image_payloads_from_comment_search_vector.cjs). Strips base64 data URIs, caps input at 500 KB. Reuse as-is.
Event bus
- Publisher:
server/src/lib/eventBus/publishers/index.ts—publishEvent(). - Existing events covering our entities:
TICKET_CREATED,TICKET_UPDATED,TICKET_CLOSED,TICKET_ASSIGNEDTICKET_COMMENT_ADDED
- Missing events that the plan must add (one feature per family):
CLIENT_CREATED/_UPDATED/_DELETEDCONTACT_*USER_*(probably already exists for auth — verify)PROJECT_*,PROJECT_PHASE_*,PROJECT_TASK_*,PROJECT_TASK_COMMENT_*ASSET_*INVOICE_*,INVOICE_ITEM_*,INVOICE_ANNOTATION_*CONTRACT_*,CLIENT_CONTRACT_*DOCUMENT_*,KB_ARTICLE_*SERVICE_CATALOG_*SERVICE_REQUEST_SUBMISSION_*,SERVICE_REQUEST_DEFINITION_*WORKFLOW_TASK_*(verify which already exist)INTERACTION_*SCHEDULE_ENTRY_*TIME_ENTRY_*BOARD_*,CATEGORY_*,TAG_*
- Add events at the corresponding actions under
server/src/lib/actions/and any model save points. Use Zod schemas inserver/src/lib/eventBus/events.ts.
withAuth example
- Canonical reference:
server/src/app/msp/service-requests/actions.tslines 67–74. - Pattern:
withAuth(async (user, { tenant }): Promise<T> => { ... }), imported from@alga-psa/auth.
Architecture file layout (new)
server/src/
lib/
search/
index.ts # registry export
types.ts # SearchDoc, SearchObjectType
normalize.ts # BlockNote/Markdown/JSONB → text + truncate
upsert.ts # writes to app_search_index
query.ts # SQL builder for FTS + pg_trgm
acl.ts # SQL predicate builder + record-level verifier
ts_headline.ts # snippet helper
indexers/
client.ts
contact.ts
user.ts
ticket.ts
ticket_comment.ts
project.ts
project_phase.ts
project_task.ts
project_task_comment.ts
asset.ts
invoice.ts
invoice_item.ts
invoice_annotation.ts
contract.ts
client_contract.ts
document.ts
kb_article.ts
service_catalog.ts
service_request_submission.ts
service_request_definition.ts
workflow_task.ts
interaction.ts
schedule_entry.ts
time_entry.ts
board.ts
category.ts
tag.ts
eventBus/
subscribers/
searchIndexSubscriber.ts # NEW
actions/
searchActions.ts # NEW — withAuth wrapper around query.ts
scripts/
search-backfill.ts # NEW — CLI; also wired into package.json
components/
search/ # NEW
SearchPalette.tsx # cmdk command palette
SearchResultRow.tsx
SearchResultGroup.tsx
useSearch.ts # debounce + server action hook
layout/
Sidebar.tsx # add search trigger at top
app/
msp/
search/
page.tsx # NEW — "see all results" page
SearchPageClient.tsx
migrations/
NNNN_create_app_search_index.cjs # NEW migration
Migration sketch
// server/migrations/NNNN_create_app_search_index.cjs
exports.up = async (knex) => {
await knex.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm');
await knex.raw(`
CREATE TABLE app_search_index (
tenant uuid NOT NULL,
object_type text NOT NULL,
object_id text NOT NULL,
parent_type text,
parent_id text,
title text NOT NULL,
subtitle text,
body text,
url text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
visible_to_user_ids uuid[] NOT NULL DEFAULT '{}',
visible_to_roles text[] NOT NULL DEFAULT '{}',
is_internal_only boolean NOT NULL DEFAULT false,
is_private boolean NOT NULL DEFAULT false,
client_scope_id uuid,
required_permission text,
search_vector tsvector NOT NULL,
search_lang text NOT NULL DEFAULT 'english',
source_updated_at timestamptz NOT NULL,
indexed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant, object_type, object_id)
)
`);
// Citus distribution — only if Citus is the active backend
await knex.raw(`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'citus') THEN
PERFORM create_distributed_table('app_search_index', 'tenant');
END IF;
END$$;
`);
await knex.raw('CREATE INDEX app_search_index_vector_gin ON app_search_index USING gin (search_vector)');
await knex.raw('CREATE INDEX app_search_index_title_trgm ON app_search_index USING gin (title gin_trgm_ops)');
await knex.raw('CREATE INDEX app_search_index_subtitle_trgm ON app_search_index USING gin (subtitle gin_trgm_ops)');
await knex.raw('CREATE INDEX app_search_index_recent ON app_search_index (tenant, source_updated_at DESC)');
await knex.raw('CREATE INDEX app_search_index_type ON app_search_index (tenant, object_type)');
};
exports.down = async (knex) => {
await knex.raw('DROP TABLE IF EXISTS app_search_index');
};
Commands / runbook
# Run the migration locally
npm run migrate
# Backfill all tenants
npm run search:backfill
# Backfill one tenant, one entity type
npm run search:backfill -- --tenant=<uuid> --type=ticket
# Manually run reconciliation
npm run search:reconcile -- --tenant=<uuid>
# Inspect index health for a tenant
psql -c "SELECT object_type, count(*), max(indexed_at) FROM app_search_index WHERE tenant = '<uuid>' GROUP BY 1 ORDER BY 1"
# Sample row
psql -c "SELECT object_type, object_id, title, left(body, 80) AS snippet FROM app_search_index WHERE tenant = '<uuid>' AND title ILIKE '%acme%' LIMIT 5"
# Drop-and-rebuild one tenant's index
psql -c "DELETE FROM app_search_index WHERE tenant = '<uuid>'" && \
npm run search:backfill -- --tenant=<uuid>
Implementation log
- 2026-05-13 — F001 complete. Added migration file
server/migrations/20260513120000_create_app_search_index.cjs. The file already includes the planned table/index/down-migration body because those pieces are inseparable from a useful migration skeleton. Validation:node --check server/migrations/20260513120000_create_app_search_index.cjs. - 2026-05-13 — F002 complete. Migration enables fuzzy matching support with
CREATE EXTENSION IF NOT EXISTS pg_trgm. Validation:rg "CREATE EXTENSION IF NOT EXISTS pg_trgm" server/migrations/20260513120000_create_app_search_index.cjs. - 2026-05-13 — F003 complete. Migration creates
app_search_indexwith PRD §9.1 columns, UUID/text ACL hint arrays,tsvectorsearch column, timestamps, and primary key(tenant, object_type, object_id). Validation:node --check ...plus targetedrgfor table, PK, ACL, and search-vector columns. - 2026-05-13 — F004 complete. Migration checks
pg_extensionforcitus, checkspg_dist_partitionfor pre-existing distribution, and only then callscreate_distributed_table('app_search_index', 'tenant'). It exportstransaction: falsebecause Citus distribution cannot run in a transaction block. - 2026-05-13 — F005 complete. Migration creates
app_search_index_vector_ginusinggin (search_vector)for FTS matching. Validation: targetedrgon the migration. - 2026-05-13 — F006 complete. Migration creates
app_search_index_title_trgmandapp_search_index_subtitle_trgmusinggin_trgm_opsfor the fuzzy fallback branch. Validation: targetedrgon both index names/opclasses. - 2026-05-13 — F007 complete. Migration creates
app_search_index_recenton(tenant, source_updated_at DESC)andapp_search_index_typeon(tenant, object_type)for recency sorting and type filtering. Validation: targetedrgon both definitions. - 2026-05-13 — F008 complete. Migration down step uses
knex.schema.dropTableIfExists('app_search_index'). Validation: targetedrgonexports.downand the drop call. - 2026-05-13 — F009 complete. Added
server/src/lib/search/types.tswithSEARCH_OBJECT_TYPEScovering the 27 CE v1 entity types and derivingSearchObjectTypefrom that tuple. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/types.ts. - 2026-05-13 — F010 complete.
SearchDocnow models tenant/type/id, optional parent, title/subtitle/body/url, metadata, required ACL metadata, andsourceUpdatedAt. Validation: targetedrgon the interface fields. - 2026-05-13 — F011 complete.
AclMetadatacoversvisibleToUserIds,visibleToRoles,isInternalOnly,isPrivate,clientScopeId, andrequiredPermissionfor indexer-produced ACL hints. Validation: targetedrgon the interface fields. - 2026-05-13 — F012 complete. Added
flattenBlockNote(json)inserver/src/lib/search/normalize.ts. It parses JSON strings when needed, walks BlockNotecontent/children/items, collects visible text leaves, supports unexpected plain text fallback, and strips image data URIs. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/normalize.ts. - 2026-05-13 — F013 complete. Added
flattenMarkdown(md)to strip headings, list markers, links/images, bold/italic/code markers, code fences, blockquotes, and HTML tags while preserving readable text. Validation: targetedrgon function and replacement rules. - 2026-05-13 — F014 complete. Added
flattenJsonbPayload(obj)to recursively concatenate string leaves from objects/arrays, skip secret-like keys (password|secret|token|api_key|authorization), strip image data URIs, and ignore scalar top-level input. Validation: targetedrgon function, secret regex, and object traversal. - 2026-05-13 — F015 complete. Added
truncateForIndex(text, maxBytes = 65_536)usingBuffer.byteLengthandfor...ofcode-point iteration so truncation respects UTF-8 byte limits without splitting characters. Validation: targetedrgon the function and byte-length loop. - 2026-05-13 — F016 complete. Added
server/src/lib/search/sql.tswithbuildTsvectorSql(title, subtitle, body). It returns a bound SQL fragment with title/subtitle/body weights A/B/C and runs all inputs throughpublic.process_large_lexemes()beforeto_tsvector('english', ...). Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/sql.ts. - 2026-05-13 — F017 complete.
EntityIndexerintypes.tsdefinesobjectType,sourceEvents,loadOne, and pagedloadBatchmethods.sourceEventsis typed asreadonly EventType[]from@alga-psa/event-schemas; loaders accept an explicittenant. Validation: targetedrgon the interface. - 2026-05-13 — F018 complete. Added
server/src/lib/search/index.tsregistry withgetIndexer,allIndexers, andregisteredObjectTypes. Added empty CEceIndexersexport and a CE-side@ee/lib/search/indexersstub returningeeIndexers = []underpackages/ee/src, matching the repo's current alias pattern. Note: the later F131 stub-registration cleanup should reconcile this with the plan'see/server/src/...wording if needed. Validation: targetedrgon registry exports and imports. - 2026-05-13 — F019 complete. Added
upsertSearchDoc(knex, doc)inserver/src/lib/search/upsert.ts. It inserts all denormalized search/ACL columns, computessearch_vectorserver-side withbuildTsvectorSql, and updates existing rows viaON CONFLICT (tenant, object_type, object_id). Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/upsert.ts. - 2026-05-13 — F020 complete. Added
deleteSearchDoc(knex, tenant, objectType, objectId)inupsert.ts; it deletes by(tenant, object_type, object_id)and is naturally a no-op when the row is absent. Validation: targetedrgon function and WHERE/delete chain. - 2026-05-13 — F021 complete. Added typed
composeAclHints(opts)inserver/src/lib/search/acl.tsand wiredupsertSearchDocthrough it. Defaults: user/role arrays empty, internal/private booleans false, optionalclientScopeIdandrequiredPermissionpassed through. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/acl.ts server/src/lib/search/upsert.ts. - 2026-05-13 — F022 complete. Added
clientIndexerwithloadOne/loadBatchagainstclients, title=client_name, subtitle=email | phone_no, body=notes, URL/msp/clients/{client_id}, and ACLrequiredPermission='client:read'. Registered it inceIndexers. Current source events use existingCLIENT_CREATED,CLIENT_UPDATED, andCLIENT_ARCHIVED; F049 should add/swap inCLIENT_DELETEDwhen that event exists. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/client.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F023 complete. Added
contactIndexerwith title=full_name, subtitle=email | phone_number | role, URL/msp/contacts/{contact_name_id}, ACLrequiredPermission='contact:read', and tenant-scopedloadOne/loadBatch. Registered it inceIndexers. Current source events use existingCONTACT_CREATED,CONTACT_UPDATED, andCONTACT_ARCHIVED; F050 should add/swap in delete semantics if needed. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/contact.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F024 complete. Added
userIndexerfor internal users only (user_type='internal'), title from first/last name with username/email/id fallback, subtitle=username | email | role, URL/msp/team/{user_id}, and ACLrequiredPermission='user:read'. Registered it inceIndexers. Current schema hasrolebut no separatetitlecolumn; F051 should addUSER_*source events and can adjust subtitle if a title column exists by then. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/user.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F025 complete. Added
ticketIndexerwith client join, title=tickets.titlefallback to ticket number/id, subtitle=client_name | ticket_number, URL/msp/tickets/{ticket_id},metadata.identifier=ticket_number, and ACLrequiredPermission='ticket:read'. Registered it inceIndexersand included current ticket events includingTICKET_DELETED. Gap: no current board-role ACL table/column was found, sovisibleToRolesremains default-empty until the ACL/query layer or a board-scope source is identified. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/ticket.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F026 complete. Added
ticketCommentIndexerjoiningcommentstotickets, title from parent ticket title/number, parent pointer(ticket, ticket_id), body=flattenMarkdown(note), URL/msp/tickets/{ticket_id}#comment-{comment_id}, ACLrequiredPermission='ticket:read', andisInternalOnlyfromcomments.is_internal. Registered it inceIndexers. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/ticket_comment.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F027 complete. Added
projectIndexerwith title=project_name, body=description, URL/msp/projects/{project_id}, ACLrequiredPermission='project:read', andclientScopeIdfromprojects.client_id. Registered it inceIndexers. Current events use existingPROJECT_CREATED,PROJECT_UPDATED, andPROJECT_STATUS_CHANGED; F052 should add delete/child publishes. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/project.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F028 complete. Added
projectPhaseIndexerjoining phases to parent projects, title=phase_name, subtitle=project_name, body=description, URL/msp/projects/{project_id}/phases/{phase_id}, parent pointer(project, project_id), and inherited project ACL/client scope. Registered it inceIndexers.sourceEventsis empty until F052 adds project phase events. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/project_phase.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F029 complete. Added
projectTaskIndexerjoining tasks through phases to projects, title=task_name, subtitle=project_name, body=description, URL/msp/projects/{project_id}/tasks/{task_id}, parent pointer(project, project_id), and inherited project ACL/client scope. Registered it inceIndexers. Source events use existing project-task event names. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/project_task.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F030 complete. Added
projectTaskCommentIndexerjoining comments through task/phase/project, title from parent task, subtitle project name, body preferringmarkdown_contentwithflattenBlockNote(note)fallback, URL/msp/projects/{project_id}/tasks/{task_id}#comment-{task_comment_id}, parent pointer(project_task, task_id), and inherited project ACL/client scope. Registered it inceIndexers; source events use existingTASK_COMMENT_*names. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/project_task_comment.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F031 complete. Added
assetIndexerwith title=name, subtitle=asset_tag | serial_number, body=location | flattenJsonbPayload(attributes), URL/msp/assets/{asset_id},metadata.identifier=asset_tag, ACLrequiredPermission='asset:read', and optionalclientScopeId. Registered it inceIndexers; source events use existingASSET_CREATED/UPDATED/ASSIGNED/UNASSIGNED. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/asset.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F032 complete. Added
invoiceIndexerjoining invoices to clients, title=invoice_number, subtitle=client_name | status | total_amount, URL/msp/invoices/{invoice_id},metadata.identifier=invoice_number, ACLrequiredPermission='invoice:read', andclientScopeIdfrominvoices.client_id. Registered it inceIndexers; source events use existing invoice lifecycle events until F054 adds CRUD-specific events. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/invoice.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F033 complete. Added
invoiceItemIndexerjoining invoice items to invoices, title parent invoice number, body item description, URL/msp/invoices/{invoice_id}#item-{item_id}, parent pointer(invoice, invoice_id), and inherited invoice ACL/client scope. Registered it inceIndexers.sourceEventsis empty until F054 adds invoice-item events. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/invoice_item.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F034 complete. Added
invoiceAnnotationIndexerjoining annotations to invoices, title parent invoice number, body annotation content, URL/msp/invoices/{invoice_id}#annotation-{annotation_id}, parent pointer(invoice, invoice_id), and inherited invoice ACL/client scope. It also mapsinvoice_annotations.is_internaltoisInternalOnlyas a conservative visibility hint. Registered it inceIndexers;sourceEventsis empty until F054 adds invoice-annotation events. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/invoice_annotation.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F035 complete. Added
contractIndexeragainstcontracts, title=contract_name, body=contract_description, subtitle=Quoteforstatus='draft'elseContract, URL/msp/billing/contracts/{contract_id},metadata.identifier=contract_name, and ACLrequiredPermission='contract:read'. Registered it inceIndexers; source events use existingCONTRACT_CREATED/UPDATED/STATUS_CHANGED. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/contract.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F036 complete. Added
clientContractIndexerjoiningclient_contracts,clients, andcontracts, title={client_name} – {contract_name}, body with start/end dates and active state, URL/msp/clients/{client_id}/contracts/{client_contract_id}, parent pointer(contract, contract_id), ACLrequiredPermission='contract:read', andclientScopeId=client_id. Registered it inceIndexers;sourceEventsis empty until F055 adds client-contract events. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/client_contract.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F037 complete. Added
documentIndexerwith title=document_name, body=truncateForIndex(flattenBlockNote(content)), URL/msp/documents/{document_id}, ACLrequiredPermission='document:read', and optionalclientScopeIdfromdocuments.client_id. It intentionally does not setisPrivateorvisibleToUserIdsbecause CE has no internal per-user document share model in v1. Registered it inceIndexers; source events use existing document lifecycle/association events. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/document.ts server/src/lib/search/indexers/index.ts. - 2026-05-13 — F038 complete. Added
kbArticleIndexerjoiningkb_articlestodocuments, title=documents.document_name, body from flattened/truncated document content, URL/msp/knowledge-base/{article_id}, parent pointer(document, document_id), and ACLrequiredPermission='kb:read'. Registered it inceIndexers;sourceEventsis empty until F056 adds KB events. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/kb_article.ts server/src/lib/search/indexers/index.ts.
Gotchas
comments.noteis plain text or markdown, not BlockNote. The ticket-comment indexer usesflattenMarkdown, notflattenBlockNote. Confirmed viaserver/src/interfaces/comment.interface.ts.project_task_comments.noteIS BlockNote. Prefermarkdown_contentif non-null; else flatten BlockNote.documents.contentis BlockNote. Always flatten + truncate.service_request_submissions.submitted_payloadis JSONB form data. Flatten string leaves; skip keys namedpassword,secret,token,api_key.- Quotes are draft contracts. Indexer marks
subtitle = 'Quote'whencontracts.status = 'draft'. channelswas renamed toboards. Use the current table name; column ischannel_name, column key ischannel_id(the rename was table-only).- Citus distribution column must be
tenant. If we ever try toJOIN app_search_index s ON s.tenant = t.tenant AND s.object_id = t.ticket_id, that join must includet.tenantto stay co-located. ts_headlineoutput contains HTML. Render via a sanitized component, neverdangerouslySetInnerHTMLof raw output.process_large_lexemes()is a Postgres function, not a Node helper. Call it viato_tsvector('english', process_large_lexemes($body))inside the indexer's SQL.
Open follow-ups (to verify at implementation time)
- Confirm existing
USER_*event semantics — do we already publish on user create/update/delete? Checkserver/src/lib/eventBus/events.tsandpackages/event-schemas/src/schemas/eventBusSchema.ts. If yes, just reuse; if no, add per F051. - Workflow task PK: confirmed below in §Schema reference —
workflow_tasks.task_idis the only PK column;tenantis a regular text column. Indexer must includetenantin every WHERE clause anyway.
Resolved (2026-05-13)
-
Snippet sanitizer → controlled-sentinel rebuild on the server. Emit
ts_headlinewith unique sentinel tokens (e.g.,«MARK»/«/MARK»), split the response on sentinels, HTML-escape each text segment, re-wrap match segments in<mark>. No DOMPurify dep on the client; the field is safe by construction. -
Query length cap → 200 chars. Enforced in Zod input.
-
time_entries.notesindexing rule →notes IS NOT NULL AND notes <> ''. No length threshold beyond non-empty. -
Board / Category / Tag → keep as result rows, not filter chips.
-
interactionsschema →title(renamed fromdescription) +notes(BlockNote JSON). Confirmed by migrationserver/migrations/20250530000000_improve_interactions_schema.cjslines 6–13. Samplenotespayload (from user):[{"id":"f3e01073-…","type":"bulletListItem","content":[{"type":"text","text":"Added Sciton Tribrid Laser\n",…}]},…]Indexer behavior:
title←interactions.titlebody←flattenBlockNote(notes)(truncated to 64 KB; falls back to raw text ifnotesis unexpectedly plain text)subtitle← derived frominteraction_types.type_name+ counterparty (client, contact, or linked ticket)acl.requiredPermission='interaction:read'
Implementation log — 2026-05-14
-
F061 — Schedule/time-entry search events.
- Added missing
TIME_ENTRY_CREATED,TIME_ENTRY_UPDATED,TIME_ENTRY_DELETED, andTIME_ENTRY_CHANGES_REQUESTEDevent types topackages/event-schemas/src/schemas/eventBusSchema.ts; kept existingTIME_ENTRY_SUBMITTEDandTIME_ENTRY_APPROVED. - Relaxed
TimeEntryEventPayloadSchemato match real payloads from both REST services and server actions (workItemTypeis stored as lowercase DB values andworkItemIdcan be non-ticket/non-project values). - Wired
scheduleEntryIndexer.sourceEventstoSCHEDULE_ENTRY_CREATED/UPDATED/DELETEDandtimeEntryIndexer.sourceEventsto all time-entry CRUD/status events. - Existing schedule actions already publish schedule events; added schedule-entry publishes for the
TimeSheetServiceschedule mutation path. - Added time-entry publishes to
packages/scheduling/src/actions/timeEntryCrudActions.tsfor create/update/delete and approval status transitions. Event publish failures are logged and do not block the user-facing action, matching the action-layer pattern used elsewhere. - Validation:
npm -w @alga-psa/event-schemas run typecheck,npm -w @alga-psa/scheduling run typecheck,git diff --check.
- Added missing
-
F062 — Board/category/tag search events.
- Added
BOARD_CREATED/UPDATED/DELETED,CATEGORY_CREATED/UPDATED/DELETED, andTAG_DEFINITION_DELETEDtopackages/event-schemas/src/schemas/eventBusSchema.ts. ExistingTAG_DEFINITION_CREATED/UPDATEDworkflow events are reused for tag index upserts. - Wired
boardIndexer,categoryIndexer, andtagIndexersourceEventsto the relevant event families. - Added board event publishes to
server/src/lib/api/services/BoardService.ts; added board/category import/delete publishes topackages/reference-data/src/actions/referenceDataActions.ts. - Added tag-definition update/delete publishes to both the package actions (
packages/tags/src/actions/tagActions.ts) and REST API service (server/src/lib/api/services/TagService.ts) so tag definition changes re-index thetag_definitionsrow. - Note: the category indexer remains scoped to the
categoriestable per the existing F047 implementation and PRD source-table choice;ticket_categoriesAPI mutations are not wired to avoid emitting event IDs the current indexer cannot load. - Validation:
npm -w @alga-psa/event-schemas run typecheck,npm -w @alga-psa/tags run typecheck,npm -w @alga-psa/reference-data run typecheck,git diff --check.
- Added
-
F063 — Search index subscriber shell.
- Added
server/src/lib/eventBus/subscribers/searchIndexSubscriber.tswith register/unregister lifecycle hooks and idempotent registration state. - Registered the subscriber in
server/src/lib/eventBus/subscribers/index.tsso normal event-bus initialization invokes it. - Deliberately kept event handling out of this commit; F064-F067 own event resolution, writes/deletes, and the
SEARCH_INDEX_LIVEgate.
- Added
-
F064 — Registry-driven subscriber resolution.
searchIndexSubscribernow builds an event-type map fromallIndexers()and subscribes to the union of every registered indexer'ssourceEvents.- Added
resolveSearchIndexersForEvent(eventType)so the event handler resolves each event to one or more indexers by registry metadata rather than a hard-coded switch. - Handler currently logs the resolved object types only; F065/F066 add upsert/delete behavior.
-
F065 — Subscriber upsert path.
- Non-delete events now extract
tenantIdplus an object-type-specific source ID from the event payload, callindexer.loadOne(knex, tenant, id), and pass the resultingSearchDoctoupsertSearchDoc. - ID extraction is centralized in
OBJECT_ID_FIELDSinsearchIndexSubscriber.ts; this absorbs the mixed camelCase/snake_case payload names used across the current event publishers. - Delete events are detected and explicitly skipped for now; F066 wires
deleteSearchDoc.
- Non-delete events now extract
-
F066 — Subscriber delete path.
- Delete-style events (
*_DELETEDplusTAG_DEFINITION_DELETED) now calldeleteSearchDoc(knex, tenant, objectType, objectId)for each resolved indexer. - Missing IDs on delete events are logged and skipped, matching the non-delete path's defensive behavior.
- Delete-style events (
-
F067 — Live-indexing gate.
- Added
isSearchIndexLiveEnabled()tosearchIndexSubscriber.ts; it returns true only whenSEARCH_INDEX_LIVE === 'true', so the default/unset behavior is disabled. - The event handler resolves and acknowledges events but returns before opening a DB connection or writing rows when live indexing is disabled.
- The env var is read at event-handling time, so future events see a changed value without code changes; process env propagation still depends on the deployment/runtime.
- Added
-
F068 — Ticket comment cascade.
- On
TICKET_UPDATED, after the ticket document is upserted, the subscriber selects all comment IDs for the same(tenant, ticket_id)and re-upserts eachticket_commentdocument. - Rationale: ticket-comment search rows denormalize the parent ticket title, so ticket title edits must refresh existing comment rows even when comment bodies did not change.
- On
-
F069 — Invoice child cascade.
- On
INVOICE_UPDATED, after the invoice document is upserted, the subscriber re-upserts invoice item and invoice annotation rows for the same(tenant, invoice_id). - Rationale: item/annotation rows denormalize invoice number and invoice client ACL hints from their parent invoice.
- On
-
F070 — Project child cascade.
- On
PROJECT_UPDATED, after the project document is upserted, the subscriber pages through phases, tasks, and task comments for the project in 500-row batches and re-upserts each child document. - Rationale: phase/task/comment rows denormalize parent project information and inherit project ACL hints, so project edits must refresh children.
- This is implemented inside the async event-bus handler rather than a separate pg-boss job for now; the work is paged and bounded per batch to avoid a single large read.
- On
-
F071 — Document-association re-index.
- Already covered by the F056 event publishes plus F065 subscriber upsert path:
DOCUMENT_ASSOCIATEDandDOCUMENT_DETACHEDcarrydocumentId, anddocumentIndexer.sourceEventsincludes both events. - When those events arrive with
SEARCH_INDEX_LIVE=true, the subscriber resolves thedocumentindexer and reloads/upserts the document row.
- Already covered by the F056 event publishes plus F065 subscriber upsert path:
-
F072 — User role-change ACL refresh job.
- Added
server/src/lib/jobs/handlers/searchVisibleUserReindexHandler.tswith job namesearch-visible-user-reindex. - The job pages through
app_search_indexrows for a tenant wherevisible_to_user_idscontains the changed user, re-runs the registered indexer for each row, upserts refreshed ACL/content, and deletes stale index rows when the source row no longer loads. - Registered the job in both
registerAllJobHandlers()and the legacyinitializeScheduler()path, and exposedscheduleSearchVisibleUserReindexJob(). searchIndexSubscribernow enqueues this job after processingUSER_ROLES_UPDATED, gated behindSEARCH_INDEX_LIVEwith the rest of live indexing. Enqueue failures are logged but do not fail the original search-index event handling.- Tightened several cascade queries from object-style
.where({ ... })to chained column predicates because the server typecheck reached those earlier subscriber lines and rejected the overload. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F073 — Search backfill CLI entrypoint.
- Created
server/src/scripts/search-backfill.tswith a typedparseSearchBackfillArgs()andrunSearchBackfill()entrypoint. - The file is intentionally a scaffold in this commit; F074-F077 fill tenant discovery, indexer selection, paging, and idempotent upsert behavior in separate commits.
- Validation:
git diff --check;npm -w server run typecheck.
- Created
-
F074 — Backfill tenant selection.
search-backfill.tsnow opens the server Knex config, discovers all tenants from thetenantscatalog by default, and accepts--tenant=<uuid>/--tenant <uuid>to run a single tenant.runSearchBackfill()accepts an optional existing Knex instance for future tests and destroys only connections it creates itself.- Validation:
git diff --check;npm -w server run typecheck.
-
F075 — Backfill indexer selection.
- The CLI now resolves indexers through the search registry: default is
allIndexers(), and--type=<object_type>/--type <object_type>narrows to one registered indexer. - Unknown object types fail fast with a typed error before any backfill loop runs.
- Validation:
git diff --check;npm -w server run typecheck.
- The CLI now resolves indexers through the search registry: default is
-
F076 — Backfill paging.
- Added a 500-row backfill loop that calls each indexer's
loadBatch(knex, tenant, cursor, 500)and advances the cursor from the last returnedSearchDoc.objectId. - The loop logs per-batch progress and stops on an empty or short page. Writes are intentionally deferred to F077.
- Validation:
git diff --check;npm -w server run typecheck.
- Added a 500-row backfill loop that calls each indexer's
-
F077 — Idempotent backfill upserts.
- The backfill loop now calls
upsertSearchDoc(knex, doc)for every loadedSearchDoc, using the existing(tenant, object_type, object_id)ON CONFLICTpath. - Re-running the CLI overwrites the same index rows with source-derived content/ACLs rather than creating duplicates.
- Validation:
git diff --check;npm -w server run typecheck.
- The backfill loop now calls
-
F078 — Root backfill npm script.
- Added root
package.jsonscriptsearch:backfill->tsx server/src/scripts/search-backfill.ts. - This matches the deployment runbook command and supports passthrough args such as
npm run search:backfill -- --tenant=<uuid> --type=client. - Validation:
git diff --check;npm -w server run typecheck.
- Added root
-
F079 — Search reconciliation job registration.
- Added
server/src/lib/jobs/handlers/searchReconcileHandler.tswith job namesearch:reconcileand registered it in bothregisterAllJobHandlers()and the legacy scheduler initialization. - The handler is a shell in this commit; F080-F082 add watermark re-indexing, missing-row inserts, and stale-index deletion.
- Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F080 — Reconcile rows updated after index watermark.
searchReconcileHandlernow resolves tenants and indexers, computesmax(source_updated_at)fromapp_search_indexper(tenant, object_type), scans source rows throughindexer.loadBatch(), and upserts anySearchDocwhosesourceUpdatedAtis newer than the watermark.- The implementation intentionally uses the indexer contract instead of per-table SQL so every entity keeps its own source joins, normalization, URL, and ACL logic.
- Validation:
git diff --check;npm -w server run typecheck.
-
F081 — Reconcile stale index deletes.
- Reconciliation now scans existing
app_search_indexrows for each registered(tenant, object_type), callsindexer.loadOne()for eachobject_id, and deletes the index row when the source no longer loads. - This also removes rows for sources that still exist but no longer qualify for indexing (for example, a time entry whose notes became empty).
- Validation:
git diff --check;npm -w server run typecheck.
- Reconciliation now scans existing
-
F082 — Reconcile missing index inserts.
- Reconciliation now scans source docs in 500-row batches, loads existing
app_search_index.object_ids for the same batch, and upserts any source doc missing from the index. - This covers backfill gaps and direct SQL deletes of index rows even when the source row's
sourceUpdatedAtis older than the current indexed watermark. - Validation:
git diff --check;npm -w server run typecheck.
- Reconciliation now scans source docs in 500-row batches, loads existing
-
F083 — Daily search reconciliation schedule.
- Added
scheduleSearchReconcileJob(tenantId, cron='0 6 * * *')and scheduled it once per tenant frominitializeScheduledJobs(). - The deploy runbook below now calls out that
search:reconcileruns daily at 6:00 AM per tenant after scheduled jobs initialize. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F084 — Query parsing.
- Added
server/src/lib/search/query.tswithparseQuery(raw). - The parser collapses whitespace, trims, rejects empty input, enforces the 200-character cap, detects
^[A-Z]+-?\d+$identifier-style queries case-insensitively, and lowercases identifier keys for later metadata matching. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F085 — FTS search query branch.
- Added
runSearchQuery()inserver/src/lib/search/query.ts. - The initial SQL path uses
websearch_to_tsquery('english', ?)andts_rank_cd(s.search_vector, q.tsq)with mandatorytenant = ?,object_type = ANY(?::text[]), andsearch_vector @@ tsqpredicates. - Results are ordered by FTS rank, recency, and object ID. ACL, trigram fallback, identifier pinning, snippets, and cursor pagination are intentionally left to F086-F093/F089-F092.
- Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F086 — pg_trgm fallback.
runSearchQuery()now includess.title % q.rawandcoalesce(s.subtitle, '') % q.rawfallback predicates in addition to FTS.- The returned score now combines
ts_rank_cdwithGREATEST(similarity(title), similarity(subtitle)) * 0.4so fuzzy-only hits can rank while still favoring FTS matches. - Validation:
git diff --check;npm -w server run typecheck.
-
F087 — Identifier match pinning.
parseQuery()already detects identifier-style input and lowercases the key;runSearchQuery()now probeslower(metadata->>'identifier')for exact matches when that key is present.- Exact identifier matches are included even if FTS/trigram do not match and receive score
1000, pinning tickets/assets/invoices/contracts with matching identifiers above normal relevance results. - Validation:
git diff --check;npm -w server run typecheck.
-
F088 — Time-decayed relevance.
- Non-identifier search scores now multiply the FTS/trigram composite by
GREATEST(exp(-age_seconds / (90 * 86400)), 0.05). - Exact identifier matches keep the explicit high score so identifier lookup remains pinned above decayed relevance results.
- Validation:
git diff --check;npm -w server run typecheck.
- Non-identifier search scores now multiply the FTS/trigram composite by
-
F089 — Opaque search cursors.
- Added
encodeSearchCursor()/decodeSearchCursor()inserver/src/lib/search/query.ts. The cursor is base64url JSON containing score, updated timestamp, and object ID. runSearchQuery()now acceptscursor; when present it applies keyset pagination against(score DESC, source_updated_at DESC, object_id ASC)and ignores offset.- Malformed cursors throw
SearchQueryError('invalid_cursor')instead of falling through to a 500-prone parse path. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F090 — Snippet generation.
runSearchQuery()now returns optionalsnippettext and includests_headline('english', coalesce(body, ''), tsq, 'MaxFragments=2,StartSel=<mark>,StopSel=</mark>')when snippets are enabled.- Added
includeSnippetsquery option, defaulting to true; F092 uses it to skip snippets for typeahead. - Validation:
git diff --check;npm -w server run typecheck.
-
F091 — Snippet sanitization.
ts_headlinenow emits controlled sentinel strings instead of literal HTML tags.- Added
sanitizeHeadline()to HTML-escape every text segment and re-wrap only sentinel-delimited matches in<mark>. - Malformed/unpaired sentinel output falls back to fully escaped text, preventing arbitrary HTML from surviving snippet generation.
- Validation:
git diff --check;npm -w server run typecheck.
-
F092 — Typeahead skips snippets.
- Added
runSearchTypeaheadQuery()wrapper aroundrunSearchQuery()that forceslimit=5andincludeSnippets=false. - The future typeahead server action can use this path without emitting
ts_headlinein its SQL. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F093 — ACL SQL predicate helper.
- Added
aclPredicateSql(user)inserver/src/lib/search/acl.ts. - The helper returns a parameterized SQL fragment covering
required_permission,visible_to_user_ids,visible_to_roles,is_internal_only,is_private, andclient_scope_id. is_privateis treated as share-list-only viavisible_to_user_ids; CE v1 document rows do not set it, but the predicate is wired for future private rows.- Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F094 — Single permission-set resolution.
- Added
resolveSearchAclPrincipal(knex, user, accessibleClientIds)inserver/src/lib/search/acl.ts. - It calls
User.getUserRolesWithPermissions()once, filters role/permission applicability by MSP vs client user type, and returns uniqueresource:actionstrings for the SQLrequired_permission = ANY(?::text[])predicate. - It also returns role names and
isInternalfor the rest of the ACL predicate. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F095 — Per-user visibility overlap predicate.
- Covered by the F093 ACL predicate: rows with non-empty
visible_to_user_idsrequirevisible_to_user_ids && ARRAY[user_id]::uuid[]. - Empty
visible_to_user_idsremains unrestricted by user ID and is controlled by the other ACL columns. - Validation:
git diff --check;npm -w server run typecheckfrom F094 still covers the helper.
- Covered by the F093 ACL predicate: rows with non-empty
-
F096 — Internal/private/client-scope ACL predicates.
- Covered by the F093 ACL predicate:
is_internal_onlyrequires an internal user,is_privaterequires membership viavisible_to_user_ids, andclient_scope_idmust be inaccessibleClientIds. - For CE v1 documents,
is_privateremains false by indexer policy; the column is still enforced for future rows or synthetic tests. - Validation:
git diff --check;npm -w server run typecheckfrom F094 still covers the helper.
- Covered by the F093 ACL predicate:
-
F097 — Record-level visibility pass framework.
- Added
registerSearchVisibilityVerifier(objectType, verifier)andverifyResultVisibility(knex, user, rows)toserver/src/lib/search/acl.ts. - Rows without a registered verifier pass through; rows with a verifier are kept only when the authoritative per-entity verifier returns true.
- F098 wires concrete entity verifiers; F099 adds drift telemetry for dropped rows.
- Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F098 — Concrete record-level visibility verifiers.
- No existing
assertTicketReadable/assertProjectReadablehelpers were found by recon; implemented equivalent source-table verifiers inacl.ts. - Ticket verifier checks source existence; ticket-comment verifier checks source existence, parent ticket existence, and internal-comment visibility.
- Project, phase, task, and task-comment verifiers check source existence plus parent project client scope; document verifier checks source existence plus
documents.client_idscope; workflow-task verifier checks source existence plusassigned_usersmembership. - Validation:
git diff --check;npm -w server run typecheck.
- No existing
-
F099 — ACL drift telemetry.
verifyResultVisibility()now emitssearch.acl_driftwhen a row passed SQL ACL filtering but failed the record-level verifier.- Telemetry is a server warning log with metric/object/user/tenant fields, plus an optional global
Sentry.captureMessage()call when a Sentry client is present in the runtime. - The repo currently has no direct Sentry package dependency, so the Sentry path is intentionally optional and dependency-free.
- Validation:
git diff --check;npm -w server run typecheck.
-
F100 — Full search server action.
- Added
server/src/lib/actions/searchActions.tswithsearchAppActionwrapped inwithAuth. - The action resolves registered object types, loads a single ACL principal/permission set, runs
runSearchQuery()with snippets and SQL ACL filtering, appliesverifyResultVisibility(), and returnsSearchAppResultrows plus grouped counts and next cursor. - Current grouped counts are computed from the visible fetched page; a broader count query can be expanded when the results page work needs full pre-pagination counts.
- Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F101 — Typeahead search server action.
- Added
searchAppTypeaheadActioninsearchActions.ts. - It uses the same registered-type and ACL resolution path as full search, but calls
runSearchTypeaheadQuery()and returns at most five rows withsnippetstripped. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F102 — Search action input schema.
- Added
searchAppInputSchemainsearchActions.tswith Zod validation forquery(trimmed, 1-200 chars),types(SearchObjectTypeenum values),limit(1-100), and optionalcursor. - Both full search and typeahead actions parse input at the action boundary before touching the database.
- Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F103 — Search action output schemas.
- Added Zod schemas for
SearchResultRow,SearchAppResult, and the typeahead result inserver/src/lib/actions/searchActions.ts. - Both authenticated search actions now parse their returned payloads at the action boundary, keeping result URLs, ISO timestamps, score values, group counts, and optional cursors under the documented contract.
- Validation:
git diff --check;npm -w server run typecheck.
- Added Zod schemas for
-
F104 — SearchPalette component.
- Added
server/src/components/search/SearchPalette.tsxas a client component usingcmdkfor the sidebar search input and suggestion list. - The component debounces typeahead queries by 200 ms against
searchAppTypeaheadAction, suppresses the popup before two trimmed characters, supports a collapsed icon button, and renders title-only suggestion rows. - Native anchor behavior, the global shortcut, and sidebar insertion remain separate feature checkpoints (F105-F108).
- Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F105 — Cmd/Ctrl+K search shortcut.
- Added a global keydown listener to
SearchPalette. Cmd+K/Ctrl+Kprevents the browser default, focuses the sidebar search input when expanded, and requests sidebar expansion before focusing when collapsed.- Validation:
git diff --check;npm -w server run typecheck.
- Added a global keydown listener to
-
F106 — Native-anchor typeahead rows.
- Typeahead suggestions now render at most five rows and each row is a real
<a href={result.url}>insidecmdk. - This preserves browser-native Cmd/Ctrl-click, middle-click, and context-menu behavior while keeping title-only suggestion text.
- Validation:
git diff --check;npm -w server run typecheck.
- Typeahead suggestions now render at most five rows and each row is a real
-
F107 — See-all typeahead row.
- Added a permanent last
cmdkrow linking to/msp/search?q={query}once the query has at least two trimmed characters. - The row uses the typeahead action's
totalCountvalue and remains a native anchor, so users can open the full results page in a new tab. - Validation:
git diff --check;npm -w server run typecheck.
- Added a permanent last
-
F108 — Sidebar launcher insertion.
- Replaced the old commented-out sidebar search placeholder in
Sidebar.tsxwith the newSearchPalette. - The collapsed sidebar renders an icon button that expands the sidebar; the expanded sidebar renders the full typeahead input above the nav.
- Validation:
git diff --check;npm -w server run typecheck.
- Replaced the old commented-out sidebar search placeholder in
-
F109 — Search results route.
- Added
server/src/app/msp/search/page.tsxas a dynamic server component. - It reads
q,type,cursor, andsortfromsearchParams, callssearchAppAction()for non-empty queries, and renders initial SSR result anchors. - The
sortparam is read and reflected in page data for URL-state continuity; query-layer sort behavior is still the F117 checkpoint. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F110 — Search page URL-state client shell.
- Added
server/src/app/msp/search/SearchPageClient.tsxand moved the route's rendered shell into it. - The results-page input is controlled from the URL's
qvalue and debouncesrouter.replace()for 200 ms on edits, preservingtypeand non-defaultsortwhile resetting cursor on new text input. - The server page still fetches initial data so cold
/msp/search?...URLs render with results in SSR output. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F111 — Results filter chips.
- Added anchor-based filter chips to
SearchPageClient:Allplus one chip for every object type present in the returnedgroupsrecord. - Each chip shows a count badge and builds a shareable
/msp/searchURL preservingqand non-defaultsortwhile setting or clearingtype. - Validation:
git diff --check;npm -w server run typecheck.
- Added anchor-based filter chips to
-
F112 — Grouped all-types results.
- When the active type filter is
All,SearchPageClientnow groups visible results by entity type and caps each rendered group at 10 rows. - Group headings use
search.groups.{objectType}with a humanized fallback and show the corresponding group count from the server action result. - Validation:
git diff --check;npm -w server run typecheck.
- When the active type filter is
-
F113 — Single-type flat results.
- The search route passes a valid
typequery parameter through tosearchAppAction, so single-type URLs fetch only that entity type. SearchPageClientrenders the non-Allbranch as a flat list of result anchors instead of grouped sections.- Validation: covered by the F112 typecheck run; no code change required beyond recording the checkpoint.
- The search route passes a valid
-
F114 — Cursor pagination controls.
- Added previous/next pagination links to
SearchPageClientusing the query layer's opaquenextCursor. - The page now accepts a lightweight
cursorStackURL parameter so a previous link can reconstruct the prior cursor boundary while keeping the canonicalcursorparameter as the active page boundary. - Text edits and filter changes intentionally drop cursor state so refreshed searches start from the first page.
- Validation:
git diff --check;npm -w server run typecheck.
- Added previous/next pagination links to
-
F115 — Loading and empty states.
- Added skeleton rows while the results-page input value differs from the URL-backed query and the 200 ms router update is pending.
- Added an empty state for zero-result searches that echoes the query and suggests removing the type filter when one is active.
- Pagination is hidden while loading or empty so stale cursor controls do not appear.
- Validation:
git diff --check;npm -w server run typecheck.
-
F116 — Native-anchor result rows.
- The shared
renderResultRow()helper inSearchPageClientrenders every row as<a href={row.url}>in both grouped and flat result modes. - This preserves browser-native new-tab and context-menu affordances on the full results page.
- Validation:
git diff --check;npm -w server run typecheck.
- The shared
-
F117 — Relevance/recent sort toggle.
- Added
sort?: 'relevance' | 'recent'to the search action input and passed it from/msp/search. runSearchQuery()now switches its keyset predicate andORDER BY: relevance uses score/recency/object_id, while recent usessource_updated_at DESC, object_id ASC.- Added a results-page segmented anchor toggle that preserves
qandtypewhile resetting cursor state on sort changes. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F118 — Search ARIA semantics.
- Added explicit combobox attributes to the sidebar search input:
role,aria-autocomplete,aria-expanded,aria-controls, andaria-activedescendant. - Added stable list/option IDs for the typeahead popup and kept
/msp/searchexposed as an ARIAregioninSearchPageClient. - Arrow-key state updates are handled in the next keyboard checkpoint (F119).
- Validation:
git diff --check;npm -w server run typecheck.
- Added explicit combobox attributes to the sidebar search input:
-
F119 — Search keyboard navigation.
- Sidebar search now handles ArrowDown/ArrowUp with wrapping selection across suggestions plus the see-all row;
aria-activedescendanttracks the selected option. - Enter opens the selected suggestion or submits to
/msp/search?q=...; Escape dismisses the typeahead without trapping Tab behavior. - Results-page input handles Enter for immediate URL submission and Escape to restore the URL-backed query and blur.
- Validation:
git diff --check;npm -w server run typecheck.
- Sidebar search now handles ArrowDown/ArrowUp with wrapping selection across suggestions plus the see-all row;
-
F120 — Stable search DOM IDs.
- Added
toDomIdPart()helpers in the search UI components so dynamic type and record IDs are normalized to lowercase kebab-case-safe fragments. - Sidebar options, full-page result rows, filter chips, sort controls, pagination links, and empty-state controls now have stable IDs with sanitized dynamic portions.
- Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F121 — English search locale keys.
- Added the
search.*namespace toserver/public/locales/en/msp/core.json. - Covered placeholders, loading/help/error text, result summaries, empty states, filters/groups for all 27 object types, sort labels, pagination labels, and the typeahead see-all row.
- Validation:
node -e "JSON.parse(require('fs').readFileSync('server/public/locales/en/msp/core.json','utf8'))";git diff --check;npm -w server run typecheck.
- Added the
-
F122 — Lang-pack propagation and validation.
- Ran
node scripts/generate-pseudo-locales.cjs, which regenerated 86 pseudo-locale files from 43 English sources. - Ran
node scripts/validate-translations.cjs; the first pass exposed missing real-localesearch.*keys, so copied the Englishsearchnamespace intode/es/fr/it/nl/pl/ptmsp/core.jsonfiles and reran validation. - Final validation passed with 0 errors and 8 pre-existing Polish plural-form warnings unrelated to search.
- Validation:
git diff --check;node scripts/validate-translations.cjs;npm -w server run typecheck.
- Ran
-
F123 — Search UI translation wiring.
- Removed English
defaultValuefallbacks fromSearchPaletteandSearchPageClientnow thatsearch.*locale keys exist. - Visible search UI text is resolved through
useTranslation('msp/core'); the only remaining hardcoded strings in these components are non-UI route/status identifiers and telemetry-style log keys. - Validation:
rg "defaultValue:" server/src/components/search/SearchPalette.tsx server/src/app/msp/search/SearchPageClient.tsxreturns no matches;git diff --check;npm -w server run typecheck.
- Removed English
-
F124 — SEARCH_INDEX_LIVE documentation/config.
- Added
SEARCH_INDEX_LIVE=falseto.env.examplewith rollout guidance: keep false through migration/backfill, then flip true for live incremental indexing. - Added
server.searchIndexLivetohelm/values.yamland wired it into the main server deployment as theSEARCH_INDEX_LIVEenvironment variable. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F125 — Deploy runbook.
- Added
docs/deployment/app-wide-search-runbook.md. - The runbook covers migrate, deploy with
SEARCH_INDEX_LIVE=false, runnpm run search:backfill, flip live indexing on, roll server/workers, sample index health, and confirmsearch:reconcile. - Validation:
git diff --check;npm -w server run typecheck.
- Added
-
F126 — Search action telemetry.
- Added structured server logs for
search.query.count,search.query.empty, andsearch.query.latency_msin bothsearchAppActionandsearchAppTypeaheadAction. - Each telemetry payload includes variant (
fullortypeahead), tenant, user ID, and latency value for the histogram-style metric. search.acl_driftwas already emitted byverifyResultVisibility()in F099 via server log plus optional Sentry capture.- Validation:
git diff --check;npm -w server run typecheck.
- Added structured server logs for
-
F127 — Per-user search rate limiting.
- Added in-memory
rate-limiter-flexibleguards at the server-action boundary. - Full search is limited to 10 requests/sec per
(tenant, user); typeahead is limited to 30 requests/sec per(tenant, user). - Limit failures throw
SearchRateLimitErrorwithstatus=429,code='SEARCH_RATE_LIMITED', andretryAfterMs. - Validation:
git diff --check;npm -w server run typecheck.
- Added in-memory
-
F128 — Ticket comment hash highlight.
- Main ticket comments now render with canonical
id="comment-{comment_id}"DOM anchors to match search index URLs. CommentItemdetects a matching#comment-{id}hash on mount, scrolls the comment into view, and applies.search-highlightplus a short visual ring/background for about two seconds.- Validation:
git diff --check;npm -w @alga-psa/tickets run typecheck;npm -w server run typecheck.
- Main ticket comments now render with canonical
Local DB availability
The MCP my-private-server query tool resolves to alga-psa-postgres-1 inside a docker network, but the local stack is stopped (alga-test-postgres exited 8w ago, no alga-psa-postgres-1 container running). To use it during implementation:
# bring the dev stack up
docker compose -f docker-compose.base.yaml -f docker-compose.ce.yaml up -d postgres
# verify
docker ps --format '{{.Names}}\t{{.Status}}' | grep postgres
Schema reference (column-level, derived from migrations)
The implementer should not need to grep migrations to know what to query. Below is a copy-pastable cheat sheet of the columns each indexer touches. If anything here diverges from reality at implementation time, the migration files are authoritative — but recheck rather than assume.
Core entity columns
clients PK (tenant, client_id)
client_id, client_name, email, phone_no, notes, url, properties (jsonb), ...
(renamed from `companies`; see 20251003000001_company_to_client_migration.cjs)
contacts PK (tenant, contact_name_id)
contact_name_id, full_name, client_id, email, phone_number, role,
is_client_admin, notes_document_id, created_at, updated_at
users PK (tenant, user_id, email)
user_id, username, first_name, last_name, email, user_type, hashed_password,
auth_method, created_at, updated_at
→ user_type='internal' for MSP team members; 'client' for client-portal users
tickets PK (tenant, ticket_id)
ticket_id, ticket_number, title, channel_id (board), client_id, contact_name_id,
assigned_to, status_id, priority_id, category_id, created_at, updated_at
comments PK (tenant, comment_id)
comment_id, ticket_id, user_id, contact_name_id,
note, is_internal (boolean), is_resolution, is_initial_description,
created_at, updated_at, metadata (jsonb)
→ `note` is plain text or markdown (NOT BlockNote)
projects PK (tenant, project_id)
project_id, project_name, description, client_id, status, contact_name_id,
start_date, end_date, created_at, updated_at
project_phases PK (tenant, phase_id)
phase_id, project_id, phase_name, description, ...
project_tasks PK (tenant, task_id)
task_id, phase_id, task_name, description, assigned_to, ...
project_task_comments PK (tenant, task_comment_id)
task_comment_id, task_id, user_id, author_type,
note (BlockNote JSON), markdown_content, created_at, updated_at, edited_at
→ prefer markdown_content; fall back to flattenBlockNote(note)
assets PK (tenant, asset_id)
asset_id, type_id, client_id, asset_tag, serial_number, name, status,
location, attributes (jsonb), created_at, updated_at
Billing / invoicing
invoices PK (tenant, invoice_id)
invoice_id, client_id, invoice_number, invoice_date, due_date,
total_amount, status, custom_fields (jsonb), billing_period, created_at, updated_at
invoice_items PK (tenant, item_id)
item_id, invoice_id, service_id, description, quantity, unit_price, total_price
invoice_annotations PK (tenant, annotation_id)
annotation_id, invoice_id, user_id, content, is_internal, created_at
contracts PK (tenant, contract_id)
contract_id, contract_name, contract_description,
billing_frequency, is_active, status, created_at, updated_at
→ status ∈ {'active','draft','terminated','expired'}; 'draft' = the "quote" tag
(renamed/restructured by 20251008000001_rename_billing_to_contracts.cjs +
202510161430_add_contract_status_column.cjs)
contract_lines PK (tenant, contract_line_id)
contract_line_id, plan_name, description, billing_frequency, is_custom,
plan_type, created_at, updated_at
→ formerly `billing_plans`
client_contracts PK (tenant, client_contract_id)
client_contract_id, client_id, contract_id, start_date, end_date, is_active,
created_at, updated_at
→ join with contracts + clients to build a search title
Documents / KB / service
documents PK (tenant, document_id)
document_id, document_name, type_id, user_id, contact_name_id, client_id,
ticket_id, created_by, edited_by, entered_at, updated_at,
content (BlockNote JSON), shared_type_id
document_associations PK (tenant, association_id)
association_id, document_id, entity_id, entity_type ∈ {'ticket','client',
'contact','schedule','project_task','quote','asset', ...}, created_at
→ NOT an internal user-share table. Used to attach docs to entities.
document_share_links PK (tenant, share_id)
share_id, document_id, token, share_type, password_hash, expires_at,
max_downloads, is_revoked, created_by, created_at
→ EXTERNAL token-based shares only; NOT used for internal ACL.
kb_articles PK (tenant, article_id)
article_id, document_id (FK), ...
→ body comes through the FK to `documents.content`
service_catalog PK (tenant, service_id)
service_id, service_name, description, service_type, default_rate,
unit_of_measure, category_id, attributes (jsonb), created_at, updated_at
service_request_definitions PK (tenant, definition_id)
definition_id, name, description, icon, category_id, form_schema (jsonb),
execution_provider, visibility_provider, lifecycle_state,
published_by, published_at, created_at, updated_at
service_request_submissions PK (tenant, submission_id)
submission_id, definition_id, definition_version_id, requester_user_id,
client_id, contact_id, request_name, submitted_payload (jsonb),
execution_status, created_ticket_id, created_at, updated_at
Workflow / activity
workflow_tasks PK (task_id) ← STRING ONLY, tenant is a column not in PK
task_id (string), tenant (string), execution_id, event_id, task_definition_id,
title, description, status, priority, due_date, context_data (jsonb),
assigned_roles (jsonb), assigned_users (jsonb), created_at, updated_at,
claimed_at, claimed_by, completed_at, completed_by, response_data (jsonb)
→ assigned_users is JSON array of user_ids; parse to uuid[] for visible_to_user_ids
interactions PK (tenant, interaction_id)
interaction_id, type_id, contact_name_id, client_id, user_id, ticket_id,
title, notes (BlockNote JSON), interaction_date, duration, status_id,
start_time, end_time, created_at, updated_at
→ `description` was renamed to `title` AND new `notes` column added in
20250530000000_improve_interactions_schema.cjs
interaction_types PK (tenant, type_id)
type_id, type_name, ...
→ join from interactions.type_id for the subtitle
schedule_entries PK (tenant, entry_id)
entry_id, title, work_item_id, work_item_type, user_id (owner),
scheduled_start, scheduled_end, status, notes, created_at, updated_at
time_entries PK (tenant, entry_id)
entry_id, user_id (owner), start_time, end_time, notes,
work_item_id, work_item_type, billable_duration,
approval_status, created_at, updated_at
→ index ONLY when notes IS NOT NULL AND notes <> ''
Metadata / structural
boards PK (tenant, channel_id)
channel_id, channel_name, ...
→ renamed from `channels` in 20250930000001_rename_channels_to_boards.cjs
→ column is still `channel_name`
categories PK (tenant, category_id)
category_id, category_name, description, ...
→ there is also `ticket_categories` (renamed from `service_categories`).
Confirm which one the UI uses; default to `categories` for v1.
tags PK (tenant, tag_id)
tag_id, channel_id, tag_text, tagged_id, tagged_type
Code patterns the implementer needs
Event bus
Canonical event publish (used at action call sites):
import { publishEvent } from 'server/src/lib/eventBus/publishers';
// publishEvent omits id + timestamp; the publisher fills them in.
await publishEvent({
eventType: 'CLIENT_UPDATED',
payload: { tenant, client_id, changed_fields: [...] },
});
Event types live in packages/event-schemas/src/schemas/eventBusSchema.ts. Each event has a matching Zod schema. To add a new event family:
- Add the event type literal to the
EventTypeEnumunion. - Add a Zod payload schema for it.
- Register the schema in the
EventPayloadSchemasmapping.
Subscriber registration
Subscribers go under server/src/lib/eventBus/subscribers/. The new searchIndexSubscriber.ts registers in server/src/lib/eventBus/initialize.ts alongside the existing subscribers (ticketEmailSubscriber, internalNotificationSubscriber, etc.). Follow the pattern in those files.
pg-boss job registration
- Scheduler entry:
server/src/lib/jobs/jobScheduler.ts - Handler registry:
server/src/lib/jobs/jobHandlerRegistry.ts
Pattern:
import { JobHandlerRegistry } from 'server/src/lib/jobs/jobHandlerRegistry';
import { JobScheduler } from 'server/src/lib/jobs/jobScheduler';
JobHandlerRegistry.register({
name: 'search:reconcile',
handler: async (jobId, data: { tenantId: string }) => {
// reconciliation logic per tenant
},
retry: { maxAttempts: 3 },
});
const scheduler = await JobScheduler.getInstance(/* … */);
await scheduler.scheduleRecurringJob('search:reconcile', '24 hours', { tenantId });
withAuth pattern
import { withAuth } from '@alga-psa/auth';
import { createTenantKnex } from '@alga-psa/db';
export const searchAppAction = withAuth(async (user, { tenant }, input: SearchAppInput) => {
const { knex } = await createTenantKnex();
// ...
});
Reference example: server/src/app/msp/service-requests/actions.ts lines 67–74.
Tests
- Runner: Vitest (config at
server/vitest.config.ts) - Unit test example:
server/src/test/unit/workflowEmptyPayloadSchema.unit.test.ts - Integration test example:
server/src/test/client-owned-contracts-resource-semantics.test.ts - Commands (per CLAUDE.md):
npm run test:unitnpm run test:integrationnpm run test:e2enpm run test:local(all)
i18n key convention
server/public/locales/en/msp/core.json uses nested camelCase, e.g.:
{
"nav": { "home": "Home", "tickets": "Tickets" },
"sidebar": { "searchPlaceholder": "Search" }
}
For the search namespace, mirror the structure:
{
"search": {
"placeholder": "Search clients, tickets, documents…",
"shortcutHint": "⌘K",
"noResults": "No results for \"{{query}}\"",
"loading": "Searching…",
"seeAllResults": "See all {{count}} results",
"filters": {
"all": "All",
"clients": "Clients",
"tickets": "Tickets",
"documents": "Documents",
"..."
},
"groups": { "...": "..." }
}
}
Run the lang-pack pipeline (generate-pseudo-locales.cjs + validate-translations.cjs) once after adding English keys to propagate to all locales.
CE / EE extension — quick reference
Full spec is in PRD §19. The short version for the implementer:
- CE owns the infrastructure.
app_search_indextable, registry, subscriber, query builder, search action, UI all live in CE. EE never duplicates these. - Registry merges two arrays:
ceIndexers(from./indexers) +eeIndexers(fromee/server/src/lib/search/indexers, stubbed to[]in CE). - CE stub file is created as part of F131. Match the existing repo CE/EE stub pattern — see
ee/server/src/lib/storage/providers/or any otheree/server/src/...that already has a CE stub for the convention. Thece-ee-stub-fixerskill describes the build-time alias mechanism. object_typeistext, not an enum. Schema is identical CE↔EE.- EE adds its own event types in
packages/event-schemas(or the EE event-schema extension point). The CE subscriber doesn't care — it dispatches byobject_typevia the merged registry. - Orphan rows (e.g., a CE deploy holding an old EE row): filtered out at query time via
object_type = ANY(registeredObjectTypes()). Reconciliation also skips unregistered types — so it won't error trying to load an EE source row that doesn't exist in CE. - What EE writes (per entity): one indexer module + one event family + i18n keys for filter/group labels. EE does not touch CE files.
Known likely EE entities (out of scope for CE v1): chat history (ee/server/migrations/20260407163000_add_chat_history_search_indexes.cjs), AI conversations/messages (ee/server/migrations/202410291100_create_ai_schema.cjs). EE planning is separate.
Document ACL — v1 scope is intentional
CE has no internal per-user document permission mechanism. Two related tables exist but neither is the right primitive:
document_associations— links a document to an entity (ticket, client, contact, …). Used for "show me docs attached to this entity," not for "user A can read this doc."document_share_links— external token-based public shares with revoke/expiry. Not internal ACL.
Decision for v1: documents are tenant-wide with required_permission='document:read' and optional client_scope_id derived from documents.client_id. The unused index columns is_private and visible_to_user_ids remain available for v2 if/when an internal share model is added — no schema change required at that time.
Concrete deploy runbook
# 1. Apply migration
npm run migrate
# 2. Deploy code with subscriber disabled
# Set in env / helm values:
# SEARCH_INDEX_LIVE=false
# 3. Backfill all tenants
npm run search:backfill
# 4. Flip env to enable live indexing
# SEARCH_INDEX_LIVE=true
# Roll workers + server
# 5. Reconciliation job (`search:reconcile`) runs daily at 6:00 AM per tenant
# from launch; first run catches anything missed between (3) and (4).
# 6. Enable the sidebar UI by merging the feature branch to main.
Implementation log
-
2026-05-13 — T061 asset CRUD event contract. Extended
searchEventPublishing.contract.test.tsto assert asset actions emitASSET_CREATED,ASSET_UPDATED, andASSET_DELETED, covering search index incremental refresh for asset rows. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T062 invoice-family event contract. Added a contract test for invoice header, item, and annotation create/update/delete event coverage. Filled the missing invoice item update publish in
packages/billing/src/models/invoice.tsand added an annotation update helper that publishesINVOICE_ANNOTATION_UPDATED, matching the search indexer source events. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed;npm -w @alga-psa/billing run typecheckpassed. -
2026-05-13 — T063 contract-family event contract. Extended the source-publishing contract test to cover
CONTRACT_*andCLIENT_CONTRACT_*CRUD events. AddedCLIENT_CONTRACT_DELETEDpublishes when deleting a contract removes its client assignments, so client-contract search rows can be removed incrementally. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed;npm -w @alga-psa/billing run typecheckpassed. -
2026-05-13 — T064 document update event contract. Added source contract coverage for document content updates, association changes, and share-link create/revoke changes emitting
DOCUMENT_UPDATED. Wired association and share-link changes to publishDOCUMENT_UPDATEDso document search rows are reindexed when client scope or share state changes. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed;npm -w @alga-psa/documents run typecheckpassed. -
2026-05-13 — T065 service catalog event contract. Extended the publishing contract test to assert both API and billing action service-catalog CRUD paths emit
SERVICE_CATALOG_CREATED,SERVICE_CATALOG_UPDATED, andSERVICE_CATALOG_DELETED. Added publishes topackages/billing/src/actions/serviceActions.tsso MSP UI mutations refresh service catalog search rows. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed;npm -w @alga-psa/billing run typecheckpassed. -
2026-05-13 — T066 service-request event contract. Added contract coverage for service-request definition/submission create, update, and delete search events. Existing create/update paths already published; added narrow delete helpers that emit
SERVICE_REQUEST_DEFINITION_DELETEDandSERVICE_REQUEST_SUBMISSION_DELETEDso index rows can be removed when these records are physically deleted. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed;npm -w server run typecheckpassed. -
2026-05-13 — T067 workflow task event contract. Added contract coverage for workflow task create/update/delete and assignment-change search events. The model already published create/update; added model helpers for assignment replacement and deletion that emit
WORKFLOW_TASK_ASSIGNMENT_CHANGEDandWORKFLOW_TASK_DELETED. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed;npm -w server run typecheckpassed. -
2026-05-13 — T068 remaining event-family contract. Added aggregate contract coverage for interaction, schedule entry, time entry, board, category, and tag CRUD event publishes. Existing interaction/schedule/time/tag paths already published; added board/category publishes in ticket UI actions to match API/reference-data coverage. Validation:
npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed;npm -w @alga-psa/tickets run typecheckpassed. -
2026-05-13 — T069 subscriber event-union contract. Added unit coverage that the search index subscriber event list equals the union of every registered indexer's
sourceEvents, and that the event resolver maps each event to all declaring indexers. Exposed a small read-only helper for the computed subscription event types so registration can stay registry-driven. Validation:npx vitest run src/test/unit/searchIndexSubscriber.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T070 subscriber create-upsert behavior. Added a fast behavior test for
CLIENT_CREATEDwith live indexing enabled: the subscriber extracts tenant/object id, calls the client indexer'sloadOne, and forwards the resultingSearchDoctoupsertSearchDoc. Exposed a test-only handler wrapper so the event handling path can be exercised without Redis. Validation:npx vitest run src/test/unit/searchIndexSubscriber.test.ts src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T071 subscriber delete behavior. Extended the subscriber behavior suite so
CLIENT_DELETEDwith live indexing enabled callsdeleteSearchDoc(knex, tenant, 'client', client_id)and does not callloadOneorupsertSearchDoc. Validation:npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T072 live-index disabled behavior. Added coverage that with
SEARCH_INDEX_LIVE=false, the subscriber resolves and acknowledges the event but does not create a tenant knex, load the source row, upsert, or delete index rows. Validation:npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T073 live-index env flip behavior. Confirmed the subscriber reads
SEARCH_INDEX_LIVEper event rather than caching it at registration: a firstCLIENT_CREATEDwhile false performs no DB writes, then flipping the env var to true in the same process lets the next event upsert normally. Validation:npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T074 missing-source cleanup. Fixed the subscriber race branch so an update/create event whose
loadOnereturns null now deletes the existing search index row for that(tenant, object_type, object_id)instead of only logging and leaving stale data. Added behavior coverage withCLIENT_UPDATED. Validation:npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T075 ticket-comment cascade. Added subscriber behavior coverage for
TICKET_UPDATED: after the ticket doc upsert, the subscriber queries the ticket's comment ids and re-loads/upserts eachticket_commentdoc so parent-title denormalization stays fresh. Validation:npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T076 invoice child cascade. Added subscriber behavior coverage for
INVOICE_UPDATED: after the invoice doc upsert, the subscriber queries invoice item ids and annotation ids, then re-loads/upserts both child entity types with inherited invoice ACL context. Validation:npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T077 project child cascade. Added subscriber behavior coverage for
PROJECT_UPDATED: after the project doc upsert, the subscriber pages through phases, tasks, and task comments via their project-scoped queries and re-loads/upserts each child doc. Validation:npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T078 document association re-index. Added subscriber behavior coverage for
DOCUMENT_ASSOCIATED: association changes resolve todocumentIndexer.loadOne, and the freshly loaded document doc (including updatedacl.clientScopeId) is upserted. Validation:npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T079 user role visible-user reindex. Added subscriber behavior coverage for
USER_ROLES_UPDATED: after the user row is re-indexed, the subscriber enqueuesscheduleSearchVisibleUserReindexJob(tenant, userId)so rows containing that user invisible_to_user_idscan refresh asynchronously. Validation:npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T080 tenant/type backfill. Added unit coverage for
runSearchBackfill({ tenant, type: 'client' }, knex): it resolves only the client indexer, skips tenant catalog discovery, callsclientIndexer.loadBatch(knex, tenant, null, 500), and upserts every returned doc. Validation:npx vitest run src/test/unit/searchBackfill.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T081 backfill batching guard. Added a synthetic 10k-row backfill test that returns exactly 500 docs for 20 pages, verifies the cursor advances by last object id, and confirms 10,000 upserts without materializing the full source set in one call. Validation:
npx vitest run src/test/unit/searchBackfill.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T082 backfill idempotency. Added an in-memory upsert simulation and ran the same backfill twice, confirming the final row map is identical after the second run even though upserts execute again. Validation:
npx vitest run src/test/unit/searchBackfill.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T083 tenant catalog discovery. Added backfill coverage with no
tenantoption: the script queriestenants, orders by tenant id, and runs the selected client indexer once per discovered tenant. Validation:npx vitest run src/test/unit/searchBackfill.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T084 backfill npm script. Added a static package-script contract that verifies root
package.jsonwiressearch:backfilltotsx server/src/scripts/search-backfill.ts. Validation:npx vitest run src/test/unit/searchBackfill.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T085 reconciliation watermark re-index. Added unit coverage for
reindexRowsAfterWatermark: with an index watermark at noon, an older source doc is skipped while a newer source doc is upserted and counted as reindexed. Validation:npx vitest run src/test/unit/searchReconcile.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T086 reconciliation stale delete. Added unit coverage for
deleteRowsMissingFromSource: indexed ids are checked withindexer.loadOne, present sources are kept, and a missing source row causesdeleteSearchDoc(knex, tenant, objectType, objectId). Validation:npx vitest run src/test/unit/searchReconcile.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T087 reconciliation missing insert. Added unit coverage for
insertRowsMissingFromIndex: source docs are compared against existingapp_search_index.object_idrows, and only source docs absent from the index are upserted. Validation:npx vitest run src/test/unit/searchReconcile.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T088 reconciliation job registration/schedule. Added a static contract that the search reconcile handler is registered in
registerAllHandlers,scheduleSearchReconcileJobusesscheduleRecurringJob<SearchReconcileJobData>, and scheduled-job startup calls it daily at0 6 * * *. Validation:npx vitest run src/test/unit/searchReconcile.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T089 query length cap. Added unit coverage that
parseQueryrejects 201-character input with the typedSearchQueryErrorcodequery_too_long. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T090 query normalization. Added parser coverage for whitespace collapse/trimming on text queries and identifier-like query normalization (
TIC-1023->tic-1023) while preserving non-identifier casing. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T091 FTS branch SQL. Added query-builder coverage that
runSearchQueryemitswebsearch_to_tsquery('english', ?)and filters withs.search_vector @@ q.tsqin the match branch. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T092 FTS ranking order. Added query-builder coverage that relevance scoring includes
ts_rank_cd(s.search_vector, q.tsq)and the default relevance sort orders byscore DESC, source_updated_at DESC, object_id ASC. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T093 pg_trgm fallback row mapping. Added query coverage that the fuzzy branch includes
s.title % q.rawandcoalesce(s.subtitle, '') % q.raw, and that a simulatedexhcangeresult row maps back as a client hit for "Exchange Systems." Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T094 trigram score contribution. Added query-builder coverage that composite relevance includes
similarity(s.title, q.raw), subtitle similarity, and the v1* 0.4trigram weight. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T095 ticket identifier pin. Added query coverage for
TIC-1023: parser binding lowercases the identifier totic-1023, SQL checksmetadata->>'identifier', assigns exact matches score1000, and the mapped ticket hit remains first in the simulated result set. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T096 asset identifier pin. Added asset-tag coverage for
LAP-0042, verifying the same identifier exact-match SQL/binding path pins an asset result with score1000. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T097 time-decay ranking. Added query-builder coverage that composite score multiplies relevance by
exp(-age/90d)usingsource_updated_at, with default relevance ordering by score then recency so newer equivalent rows win. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T098 time-decay floor. Added query-builder coverage that the time-decay multiplier is wrapped in
GREATEST(..., 0.05)so very old rows retain the v1 minimum score multiplier. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T099 cursor round-trip. Added unit coverage that
encodeSearchCursoranddecodeSearchCursorpreserve score, ISOupdatedAt, and object id for stable pagination boundaries. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T100 cursor pagination stability. Added query coverage that a decoded cursor binds strict relevance/recency/object-id predicates and resets offset to zero, preventing page-one rows from reappearing on page two. Validation:
npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T101 snippet sentinel SQL. Added query-builder coverage that snippet generation uses
ts_headlinewith controlled__SEARCH_MARK_START__/__SEARCH_MARK_STOP__sentinels rather than raw HTML tags. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T102 snippet sanitizer. Added direct sanitizer coverage showing arbitrary
<script>/<b>source text is HTML-escaped while only sentinel-marked matches are rebuilt as<mark>...</mark>. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T103 malformed snippet sentinels. Added fail-safe sanitizer coverage for unpaired/out-of-order sentinels, confirming the function escapes the full snippet instead of throwing or emitting unsafe HTML. Validation:
npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T104 typeahead skips headline SQL. Fixed
runSearchQuerysoincludeSnippets=falseemitsNULL AS snippetand does not includets_headlinein the SQL at all; updated cursor-binding assertions after removing the old boolean snippet binding. Validation:npx vitest run src/test/unit/searchQuery.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T105 required-permission ACL predicate. Added ACL SQL coverage that
required_permissionis checked against the single resolved permissions array binding viaANY(?::text[]), so permissions not in that set cannot pass the predicate. Validation:npx vitest run src/test/unit/searchAcl.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T106 action ACL resolution once. Added a mock-based
searchAppActiontest withwithAuthas identity, confirmingresolveSearchAclPrincipalruns exactly once per action call and its ACL object is reused for bothrunSearchQueryandverifyResultVisibility. Validation:npx vitest run src/test/unit/searchActions.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T107 visible-user restriction predicate. Added ACL SQL coverage that rows with non-empty
visible_to_user_idsrequire overlap with the current user's UUID viavisible_to_user_ids && ARRAY[?]::uuid[]; users not in the array cannot match that branch. Validation:npx vitest run src/test/unit/searchAcl.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T108 visible-user unrestricted branch. Added ACL SQL coverage that empty
visible_to_user_idsrows pass through thecardinality(visible_to_user_ids) = 0 OR ...branch for users who have the required permission. Validation:npx vitest run src/test/unit/searchAcl.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T109 internal-only ACL gate. Added ACL SQL coverage that internal-only rows require
isInternal=true; client-type/non-internal users bindfalseand therefore cannot passis_internal_only=truerows. Validation:npx vitest run src/test/unit/searchAcl.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T110 private-row ACL gate. Added ACL SQL coverage that
is_private=truerows require overlap withvisible_to_user_ids, using the current user's UUID binding. Validation:npx vitest run src/test/unit/searchAcl.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T111 client-scope ACL gate. Added ACL SQL coverage that scoped rows require
client_scope_id = ANY(?::uuid[]), binding only the user's accessible client ids. Validation:npx vitest run src/test/unit/searchAcl.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T112 record-level visibility drop. Added verifier coverage using a ticket search row whose authoritative ticket lookup returns no source row;
verifyResultVisibilitydrops the row and emits the drift log. Validation:npx vitest run src/test/unit/searchAcl.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T113 ACL drift telemetry. Added verifier coverage that a record-level rejection calls the optional Sentry
captureMessage('search.acl_drift', ...)hook with object/user metadata, in addition to the server warning log. Validation:npx vitest run src/test/unit/searchAcl.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T114 zero-drift visibility path. Added verifier coverage where the ticket source lookup succeeds; the row is preserved and no
search.acl_driftSentry capture is emitted. Validation:npx vitest run src/test/unit/searchAcl.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T115 grouped action result. Extended the search action unit suite with client + ticket hits and verified
searchAppActionreturnstotalCountplusgroupscounts per object type (including zero for unrelated types). Validation:npx vitest run src/test/unit/searchActions.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T116 withAuth wrapping contract. Added a source-level action contract that
searchAppActionis exported throughwithAuth; the auth package's wrapper owns the unauthenticated throw behavior. Validation:npx vitest run src/test/unit/searchActions.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T117 action tenant isolation. Added action coverage that the authenticated tenant context is passed through to
runSearchQuery, and a different tenant is never supplied by the action path. The SQL-level tenant predicate is separately covered in query tests. Validation:npx vitest run src/test/unit/searchActions.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T118 typeahead result shape. Added typeahead action coverage that only the first five visible hits are returned,
totalCountreflects the full visible set, and snippets are stripped from every row. Validation:npx vitest run src/test/unit/searchActions.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T119 typeahead overhead budget. Added a mocked action-path latency guard showing
searchAppTypeaheadActionoverhead stays below 100 ms when the query layer is fast. A real seeded-medium p50 check still belongs in integration/load infrastructure. Validation:npx vitest run src/test/unit/searchActions.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T120 action input validation. Added schema coverage that rejects empty queries, queries over 200 chars, unknown object types, and
limit > 100. Validation:npx vitest run src/test/unit/searchActions.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T121 result URL output schema. Added output-schema coverage that result rows with an empty URL are rejected, enforcing non-empty canonical links at the action boundary. Validation:
npx vitest run src/test/unit/searchActions.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T122 sidebar search placement. Added a static UI contract that
Sidebar.tsximportsSearchPaletteand renders it before the main<nav>, keeping search at the top of the MSP sidebar. Validation:npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T123 search keyboard launcher. Added UI contract coverage that
SearchPalettelistens for Cmd/Ctrl+K and focuses the search input, including the collapsed-sidebar expansion path. Validation:npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T124 typeahead anchor rows. Added UI contract coverage that typeahead limits visible results to five and renders each result via
Command.Item asChildwith a native<a href={result.url}>. Validation:npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T060 project-family event contract. Extended
searchEventPublishing.contract.test.tsto assert project actions emit project create/update/delete and phase create/update/delete events, task actions emit task create/update/delete events, and task-comment actions emit task-comment create/update/delete events. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T059 user CRUD/role event contract. Extended
searchEventPublishing.contract.test.tsto assertpackages/users/src/actions/user-actions/userActions.tsemitsUSER_CREATED,USER_UPDATED,USER_DELETED, andUSER_ROLES_UPDATEDwith tenant context and stable idempotency keys, covering user role-change ACL reindex triggers. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T058 contact CRUD event contract. Added a
ContactService.deleteoverride so API contact deletion now loads the contact, deletes it tenant-scoped, then publishesCONTACT_DELETEDwith contact id, optional client id, deleting user, tenant context, and an idempotency key. ExtendedsearchEventPublishing.contract.test.tsto assert CONTACT_CREATED/UPDATED/DELETED publish contracts inContactService. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed;npm -w server run typecheckpassed. -
2026-05-13 — T057 client delete event contract. Extended
searchEventPublishing.contract.test.tsto assert the client deletion path emitsCLIENT_DELETEDwithclientId, deleting user, deletion timestamp, tenant context, and a stable delete idempotency key. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T056 client update event contract. Extended
searchEventPublishing.contract.test.tsto assert the client update action builds aCLIENT_UPDATEDpayload withclientId, publishes it withtenantId: tenantcontext, and uses a stable client-updated idempotency key. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T055 client create event contract. Added
server/src/test/unit/searchEventPublishing.contract.test.tsto assert the client creation action emitsCLIENT_CREATEDthroughpublishWorkflowEvent, includescreatedClient.client_idin the payload builder, carriestenantId: tenantin context, and uses a stable client-created idempotency key. Validation:npx vitest run src/test/unit/searchEventPublishing.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T054 board/category/tag ACL. Extended
searchIndexers.test.tsto assert board, category, and tag indexers produce result rows with titles andacl.requiredPermission='ticket:read', matching the PRD rule that structural ticket metadata is searchable as normal rows. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T053 time entry non-empty notes indexing. Extended
searchIndexers.test.tsto asserttimeEntryIndexer.loadOneproduces atime_entrySearchDoc for a one-character note, links to the parent ticket, and scopes visibility to the time-entry owner. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T052 time entry empty-notes skip. Extended
searchIndexers.test.tsto asserttimeEntryIndexer.loadOneaddswhereNotNull('te.notes')andte.notes <> ''filters and returnsnullwhen no row survives those filters, enforcing the PRD skip rule for empty notes. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T051 schedule entry assignee ACL. Extended
searchIndexers.test.tsto assertscheduleEntryIndexer.loadOneaggregates schedule assignees, maps them intoacl.visibleToUserIds, and preservesschedule:read, body notes, and schedule URL. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T050 workflow task assignee ACL. Extended
searchIndexers.test.tsto assertworkflowTaskIndexer.loadOneparsesassigned_usersJSONB entries (user_id,userId, and string forms), deduplicates them, and writes the resulting IDs intoacl.visibleToUserIdswithworkflow_task:read. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T049 service request definition admin ACL. Extended
searchIndexers.test.tsto assertserviceRequestDefinitionIndexer.loadOnemaps definition title/body/url and setsacl.requiredPermission='admin', matching the PRD's admin-only visibility rule. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T048 service request submission payload filtering. Extended
searchIndexers.test.tsto assertserviceRequestSubmissionIndexer.loadOneflattens safe submitted payload strings into the body, excludes secret-like payload keys/values, and setsservice_request:readplus optional client scope. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T047 service catalog attributes. Extended
searchIndexers.test.tsto assertserviceCatalogIndexer.loadOneincludes both service description and flattened JSONB attribute string values in the indexed body withservice_catalog:readACL. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T046 KB article document join. Extended
searchIndexers.test.tsto assertkbArticleIndexer.loadOnejoinskb_articlestodocuments, uses document name/content for title/body, sets document parent metadata, and requireskb:read. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T045 document ACL defaults. Extended
searchIndexers.test.tsto assertdocumentIndexer.loadOnesetsacl.clientScopeIdfromdocuments.client_idand intentionally leaves v1-unused private/share-list hints (isPrivate,visibleToUserIds) unset. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T044 document body byte cap. Extended
searchIndexers.test.tswith a large BlockNote document fixture and asserteddocumentIndexer.loadOnetruncates flattened body content to at most 65,536 UTF-8 bytes before indexing. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T043 active contract label. Extended
searchIndexers.test.tsto assertcontractIndexer.loadOnemaps an active contract tosubtitle='Contract', keeping quote labeling limited to draft contracts. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T042 draft contract quote label. Extended
searchIndexers.test.tsto assertcontractIndexer.loadOnemapsstatus='draft'tosubtitle='Quote', keeps contract body/identifier metadata, and requirescontract:read. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T041 invoice child ACL inheritance. Extended
searchIndexers.test.tsto assertinvoiceItemIndexerandinvoiceAnnotationIndexerjoin their parent invoice, use invoice number as title, emit item/annotation hash URLs, and inheritinvoice:readplusclientScopeIdfrom the invoice. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T040 invoice client subtitle and identifier. Extended
searchIndexers.test.tsto assertinvoiceIndexer.loadOnejoins clients by tenant/client id, builds subtitle from client name/status/total, setsmetadata.identifierto the invoice number, and scopes ACL by invoice client. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T039 asset identifier metadata. Extended
searchIndexers.test.tsto assertassetIndexer.loadOnecopiesasset_tagintometadata.identifier, enabling exact identifier ranking for asset-tag searches such asLAP-0042. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T038 asset JSONB attribute flattening. Extended
searchIndexers.test.tsto assertassetIndexer.loadOneincludes location plus flattened JSONB attribute string values in the body while excluding secret-like keys/values such aspassword. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T037 project-task-comment BlockNote fallback. Extended
searchIndexers.test.tsto assertprojectTaskCommentIndexer.loadOneflattens BlockNote JSON fromnotewhenmarkdown_contentis null, preserving searchable visible comment text. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T036 project-task-comment markdown precedence. Extended
searchIndexers.test.tsto assertprojectTaskCommentIndexer.loadOneusesmarkdown_contentas the indexed body when both markdown and BlockNotenotecontent are present, while still inheriting project ACL fields. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T035 project phase/task inherited ACL. Extended
searchIndexers.test.tsto exerciseprojectPhaseIndexer.loadOneandprojectTaskIndexer.loadOnewith parent project rows, asserting both emitproject:read, inheritclientScopeIdfrom the joined project, set project parent metadata, and use the project name as subtitle. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T034 project client-scope ACL. Extended
searchIndexers.test.tsto assertprojectIndexer.loadOnemaps project title/body/url and setsacl.clientScopeIdfromprojects.client_idwithrequiredPermission='project:read'. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T033 ticket-comment anchor URL. Extended
searchIndexers.test.tswith a public ticket-comment fixture and assertedticketCommentIndexer.loadOneemits/msp/tickets/{ticket_id}#comment-{comment_id}, preserving the hash anchor used by search results. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T032 ticket-comment internal ACL. Extended
searchIndexers.test.tswith a mocked comment/ticket join to assertticketCommentIndexer.loadOnescopes by comment tenant/id, inherits ticket context, flattens markdown comment body, and mapscomments.is_internal=truetoacl.isInternalOnly=truewithticket:read. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T031 ticket indexer subtitle and identifier. Extended
searchIndexers.test.tswith a mocked ticket/client join to assertticketIndexer.loadOnejoins clients by tenant/client id, filters by tenant and ticket id, denormalizesclient_name+ticket_numberinto the subtitle, and exposesticket_numberasmetadata.identifier. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T030 user indexer internal-only filter. Extended
searchIndexers.test.tsto assertuserIndexer.loadOneadds theuser_type = 'internal'predicate alongside the user id filter and returnsnullwhen that filtered query finds no row, preventing client-portal users from being indexed as team members. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T029 contact subtitle composition. Extended
searchIndexers.test.tsto exercisecontactIndexer.loadOneand assert it queriescontacts, filters bycontact_name_id, and builds the subtitle from email, phone number, and role while preserving the contact URL andcontact:readACL. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T028 client batch backfill mapping. Extended
searchIndexers.test.tswith a thenable mockedclientsquery to exerciseclientIndexer.loadBatchas the backfill CLI uses it. The test asserts tenant scoping, stableclient_idordering, batch limit, and one returnedSearchDocper seeded client row withclient:readACL. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T027 client indexer loadOne mapping. Added
server/src/test/unit/searchIndexers.test.tswith a mockedclientsquery chain to assertclientIndexer.loadOnefilters by tenant and client id, mapsclient_nameto title, email/phone to subtitle, notes to body, canonical client URL, andrequiredPermission='client:read'. Validation:npx vitest run src/test/unit/searchIndexers.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T026 concurrent upsert conflict contract. Extended
searchUpsert.test.tswith two concurrentupsertSearchDoccalls for the same(tenant, object_type, object_id)and an in-memory raw handler keyed by that conflict target. The test asserts both calls useON CONFLICT (tenant, object_type, object_id), only one logical row remains, and the later call's searchable fields win. Validation:npx vitest run src/test/unit/searchUpsert.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T025 search index delete helper. Extended
searchUpsert.test.tswith a mocked Knex query-builder chain to assertdeleteSearchDoctargetsapp_search_index, scopes the delete by tenant/object_type/object_id, and resolves cleanly when the underlying delete affects zero rows. Validation:npx vitest run src/test/unit/searchUpsert.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T024 upsert conflict refresh. Extended
searchUpsert.test.tsto assert theON CONFLICTbranch refreshes title/body/source timestamp, assignssearch_vector = EXCLUDED.search_vector, and setsindexed_at = now(). The test also checks updated title/body values flow into the SQL bindings. Validation:npx vitest run src/test/unit/searchUpsert.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T023 upsert insert SQL path. Added
server/src/test/unit/searchUpsert.test.tswith a mocked Knexrawcall to assertupsertSearchDocemits a singleINSERT INTO app_search_indexstatement, includes the primary-key conflict target, writessearch_vector, and binds the expected tenant/type/id/title/body/url/metadata values for a new client search doc. Validation:npx vitest run src/test/unit/searchUpsert.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T022 registry exposes CE indexers. Added
server/src/test/unit/searchRegistry.test.tsto import the real registry through the CE/EE alias path and assertallIndexers()/registeredObjectTypes()expose 27 unique CE search object types and thatgetIndexer('client')resolves the client indexer. Validation:npx vitest run src/test/unit/searchRegistry.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T021 weighted tsvector SQL helper. Added
server/src/test/unit/searchSql.test.tsto assertbuildTsvectorSqlemitspublic.process_large_lexemes(?)for title/subtitle/body with weights A/B/C, composes the weighted vectors with||, and returns the expected bindings. Validation:npx vitest run src/test/unit/searchSql.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T020 truncation no-op boundary. Extended
searchNormalize.test.tsto asserttruncateForIndexreturns the exact original string, including multibyte content, when the UTF-8 byte length is already under the configured limit. Validation:npx vitest run src/test/unit/searchNormalize.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T019 UTF-8 truncation byte cap. Extended
searchNormalize.test.tswith a multibyte emoji string and a byte limit that would split the emoji if truncation were byte-slice based. The test assertstruncateForIndexstays within the byte cap, returns only complete code points, and emits no replacement character. Validation:npx vitest run src/test/unit/searchNormalize.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T018 JSONB payload scalar boundary. Extended
searchNormalize.test.tsto assertflattenJsonbPayloadreturns an empty string for nullish and scalar values (null,undefined, string, number, boolean), preserving the contract that only object/array JSONB containers contribute text. Validation:npx vitest run src/test/unit/searchNormalize.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T017 JSONB payload secret filtering. Extended
searchNormalize.test.tswith a nested JSONB fixture containing ordinary string leaves pluspassword,api_key,authorization, andsecret*keys. The test assertsflattenJsonbPayloadconcatenates only safe string leaves in traversal order and excludes secret-like values. Validation:npx vitest run src/test/unit/searchNormalize.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T016 Markdown flattening. Extended
searchNormalize.test.tswith markdown containing a heading, bullet list, bold/italic markers, link syntax, fenced code, and blockquote. The test assertsflattenMarkdownstrips formatting syntax while preserving readable content. Validation:npx vitest run src/test/unit/searchNormalize.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T015 nested BlockNote list flattening. Extended
searchNormalize.test.tswith a four-level nested BlockNote list fixture using inline mark styles. The test assertsflattenBlockNotedoes not throw and returns all nested visible text in order. Validation:npx vitest run src/test/unit/searchNormalize.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T014 BlockNote image data stripping. Extended
searchNormalize.test.tswith a BlockNote text payload containing adata:image/png;base64,...string between visible text leaves. The test asserts the data URI and base64 fragment are absent fromflattenBlockNoteoutput while surrounding visible text remains. Validation:npx vitest run src/test/unit/searchNormalize.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T013 BlockNote visible-text flattening. Added
server/src/test/unit/searchNormalize.test.tswith a realistic BlockNote fixture containing headings, nested bullet-list content, inline marks, and multiple text leaves. The test assertsflattenBlockNoteemits the visible text in document order without JSON/formatting noise. Validation:npx vitest run src/test/unit/searchNormalize.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T012 SearchDoc/AclMetadata compile-time ACL contract. Extended
searchTypes.exhaustive.test.tsto build aSearchDocwith everyAclMetadatadenormalized hint (visibleToUserIds,visibleToRoles,isInternalOnly,isPrivate,clientScopeId,requiredPermission) and to use@ts-expect-errorfor aSearchDocmissingacl, sonpm -w server run typecheckfails if ACL metadata ever becomes optional. Validation:npx vitest run src/test/unit/searchTypes.exhaustive.test.ts --coverage=falsefromserver/passed;npm -w server run typecheckpassed. -
2026-05-13 — T011 SearchObjectType exhaustive switch. Added
server/src/test/unit/searchTypes.exhaustive.test.tswith aSearchObjectTypeswitch that covers all 27 current object types and assigns the default arm tonever, so adding a new type forces the switch to be updated at compile time. Validation:npx vitest run src/test/unit/searchTypes.exhaustive.test.ts --coverage=falsefromserver/passed;npm -w server run typecheckpassed. -
2026-05-13 — T010 migration down/up cycle. Extended
searchMigration.contract.test.tsto executedownagainst a mockedknex.schema.dropTableIfExistsand then execute the mocked non-Citusuppath again, asserting the table drop targetsapp_search_indexand the create-table path still runs. Validation:npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T009 search index btree indexes. Extended
searchMigration.contract.test.tsto assert the migration createsapp_search_index_recent ON app_search_index (tenant, source_updated_at DESC)andapp_search_index_type ON app_search_index (tenant, object_type). Validation:npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T008 subtitle trigram planner contract. Extended
searchMigration.contract.test.tsto assert the migration createsapp_search_index_subtitle_trgm ON app_search_index USING gin (subtitle gin_trgm_ops)and that the query path uses bothcoalesce(s.subtitle, '') % q.rawandsimilarity(coalesce(s.subtitle, ''), q.raw). Validation:npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T007 title trigram planner contract. Extended
searchMigration.contract.test.tsto assert the migration createsapp_search_index_title_trgm ON app_search_index USING gin (title gin_trgm_ops)and that the query path uses boths.title % q.rawandsimilarity(s.title, q.raw)for fuzzy matching/ranking. Validation:npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T006 search_vector GIN planner contract. Local Postgres/Citus is still unavailable, so the test uses a static planner contract rather than live
EXPLAIN:searchMigration.contract.test.tsnow asserts the migration createsapp_search_index_vector_gin ON app_search_index USING gin (search_vector)and thatserver/src/lib/search/query.tsuses the indexeds.search_vector @@ q.tsqpredicate. Validation:npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T005 no-Citus migration path. Extended
searchMigration.contract.test.tswith a mocked migrationupexecution wherepg_extensionreports Citus is absent. The test asserts the migration never checkspg_dist_partition, never callscreate_distributed_table, and emits the documented skip warning instead of failing. Validation:npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T004 Citus distribution migration path. Extended
searchMigration.contract.test.tswith a mocked migrationupexecution where thecitusextension is present. The test asserts the migration checkspg_dist_partitionforapp_search_indexand callsSELECT create_distributed_table('app_search_index', 'tenant')only after confirming the table is not already distributed. Validation:npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T003 pg_trgm idempotent extension contract. Extended
searchMigration.contract.test.tsto assert the migration containsCREATE EXTENSION IF NOT EXISTS pg_trgmand not the non-idempotentCREATE EXTENSION pg_trgmform. Validation:npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T002 search index primary key contract. Extended
searchMigration.contract.test.tsto asserttenant uuid NOT NULLexplicitly and thePRIMARY KEY (tenant, object_type, object_id)clause in the migration'sCREATE TABLEbody. Validation:npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=falsefromserver/passed. -
2026-05-13 — T001 migration table column contract. Added
server/src/test/unit/searchMigration.contract.test.ts, which parsesserver/migrations/20260513120000_create_app_search_index.cjsand asserts the exactapp_search_indexcolumn names and SQL definitions from PRD §9.1. Validation:npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=falsefromserver/passed. Note: an initialnpm -w server run test:unit -- searchMigration.contract.test.tsinvocation expanded to the whole unit suite because of the package script and was killed after unrelated existing failures; it was not used as the gate. -
2026-05-13 — F134 reconciliation skips unregistered types. Normal reconciliation already iterates
allIndexers(), so orphaned rows with unregistered object types are never selected for source loading. Tightened the targetedtypepath as well:resolveReconcileIndexersnow logs and returns an empty list whengetIndexer(type)is missing instead of throwing. This lets synthetic/old EE object types remain untouched in CE builds. Validation:git diff --check;npm -w server run typecheck. -
2026-05-13 — F133 registry-driven search filters/groups.
/msp/searchnow readsregisteredObjectTypes()on the server page and passes the filtered registered CE/EE object types intoSearchPageClient. The client renders exactly those registered types for filter chips and grouped result sections, while retaining i18n keys (search.filters.*/search.groups.*) and falling back to a humanizedobject_typewhen an EE type lacks CE locale entries. Invalid or unregisteredtype=params are ignored before callingsearchAppAction. Validation:git diff --check;npm -w server run typecheck. -
2026-05-13 — F132 registered-type orphan safety. No code change was needed:
searchAppActionandsearchAppTypeaheadActioncallresolveAllowedTypes, which intersects any requested types withregisteredObjectTypes(), andrunSearchQueryalways appliess.object_type = ANY(?::text[])in SQL. That means CE builds cannot return stale EE-only rows such asee_chat_historybecause there is no registered indexer for that type. Validation: inspectedserver/src/lib/actions/searchActions.tsandserver/src/lib/search/query.ts;npm -w server run typecheckwas already clean after F131. -
2026-05-13 — F131 CE/EE search indexer stub. The repo's CE-first alias convention maps
@ee/*topackages/ee/src/*inserver/tsconfig.json, with EE builds overriding the alias toee/server/src. The search registry already imports@ee/lib/search/indexers, so no separate stub-generator registration was needed. Typed the CE stub atpackages/ee/src/lib/search/indexers/index.tsasEntityIndexer[] = []and added the matching empty EE-side module atee/server/src/lib/search/indexers/index.tsso both alias targets resolve until EE contributes real indexers. Validation:git diff --check;npm -w server run typecheck. -
2026-05-13 — F130 project task comment hash highlights. Search result URLs for project tasks/comments use
/msp/projects/{project_id}/tasks/{task_id}#comment-{task_comment_id}, while the current project UI opens task editors from/msp/projects/{project_id}?taskId={task_id}. Added a lightweight/msp/projects/[id]/tasks/[taskId]client redirect that preserves the hash and forwards to the existing project page query shape.ProjectPagenow preserves a#comment-*hash during the initial URL normalization for the same task, andTaskCommentexposesid="comment-{taskCommentId}", scrolls it into view, and applies a brief.search-highlightstyle. Validation:git diff --check;npm -w @alga-psa/projects run typecheck;npm -w server run typecheck. -
2026-05-13 — F129 invoice item/annotation hash highlights. Invoice search result URLs target
/msp/invoices/{invoice_id}#item-{item_id}and#annotation-{annotation_id}, but the current MSP invoice view is hosted in the billing invoicing tab rather than a standalone invoice page. Added a lightweight/msp/invoices/[id]client redirect that preserves the hash and forwards to/msp/billing?tab=invoicing&subtab=finalized&invoiceId={id}.InvoicePreviewPanelnow renders hidden item and annotation anchor targets, scrolls the current hash into view, and applies a brief.search-highlighttreatment. Also madegetInvoiceAnnotationstenant-scoped instead of a placeholder so annotation anchor content can be loaded. Validation:git diff --check;npm -w @alga-psa/billing run typecheck;npm -w server run typecheck. -
2026-05-13 — F039 service catalog indexer. Added
serviceCatalogIndexerand registered it in the CE indexer array. It indexesservice_catalog.service_nameas title, combinesdescriptionwith flattenedattributesJSONB for the body, links to/msp/billing/services/{service_id}, and setsrequiredPermission='service_catalog:read'.sourceEventsstays empty until the service-catalog event family is added in F057. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/service_catalog.ts server/src/lib/search/indexers/index.ts. -
2026-05-13 — F040 service request submission indexer. Added
serviceRequestSubmissionIndexerand registered it. It indexesrequest_name, flattenssubmitted_payloadvia the secret-skipping JSONB flattener, links to/msp/service-requests/{submission_id}, setsrequiredPermission='service_request:read', and carriesclient_idintoclientScopeId.sourceEventsremains empty until F058 adds the service-request event family. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/service_request_submission.ts server/src/lib/search/indexers/index.ts. -
2026-05-13 — F041 service request definition indexer. Added
serviceRequestDefinitionIndexerand registered it. It indexesservice_request_definitions.nameanddescription, links to/msp/service-requests/definitions/{definition_id}, and sets the admin-only ACL hint withrequiredPermission='admin'. Event hooks remain empty until F058. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/service_request_definition.ts server/src/lib/search/indexers/index.ts. -
2026-05-13 — F042 workflow task indexer. Added
workflowTaskIndexerwith explicittenantpredicates on bothloadOneandloadBatch, even thoughworkflow_tasks.task_idis the only PK in the current schema. It indexestitleanddescription, links to/msp/workflow-tasks/{task_id}, setsrequiredPermission='workflow_task:read', and parsesassigned_usersJSONB into de-duplicatedvisibleToUserIdsfrom string arrays or object arrays (user_id,userId,id). Event hooks remain empty until F059. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/workflow_task.ts server/src/lib/search/indexers/index.ts. -
2026-05-13 — F043 interaction indexer. Added
interactionIndexerand registered it. It flattens/truncates BlockNoteinteractions.notes, builds subtitles frominteraction_types.type_nameplus any available client/contact/ticket labels, links to/msp/interactions/{interaction_id}, and setsrequiredPermission='interaction:read'. Current schema allows a nullable title because it was renamed from legacydescription, so the indexer falls back toUntitled interactiononly when the stored title is blank. Event hooks remain empty until F060. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/interaction.ts server/src/lib/search/indexers/index.ts. -
2026-05-13 — F044 schedule entry indexer. Added
scheduleEntryIndexerand registered it. Current migrations removedschedule_entries.user_idand useschedule_entry_assignees, so the indexer aggregates assigneeuser_ids from that pivot table intovisibleToUserIdsinstead of reading a non-existent owner column. It indexestitle/notes, links to/msp/schedule/{entry_id}, and setsrequiredPermission='schedule:read'. Event hooks remain empty until F061. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/schedule_entry.ts server/src/lib/search/indexers/index.ts. -
2026-05-13 — F045 time entry indexer. Added
timeEntryIndexerand registered it. It enforces the PRD rule in SQL (notes IS NOT NULL AND notes <> ''), indexes note text only for rows with content, carriestime_entries.user_idintovisibleToUserIds, and setsrequiredPermission='time:read'. URLs point at the parent ticket, project task, or interaction when enough work-item data exists, with/msp/time-entries/{entry_id}as a fallback. Event hooks remain empty until F061. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/time_entry.ts server/src/lib/search/indexers/index.ts. -
2026-05-13 — F046 board indexer. Added
boardIndexerand registered it. Current migrations createboards.board_id/boards.board_name(the scratchpad'schannel_id/channel_namenote reflects an older rename state), so the indexer uses the current columns, links to/msp/tickets?board={board_id}, and setsrequiredPermission='ticket:read'. Event hooks remain empty until F062. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/board.ts server/src/lib/search/indexers/index.ts. -
2026-05-13 — F047 category indexer. Added
categoryIndexerand registered it. Ticket categories are still read from thecategoriestable in app code, withboard_idbackfilled during the board migration, so the indexer usescategories.category_id/category_name, links to/msp/tickets?category={category_id}, includesboard_idmetadata when present, and setsrequiredPermission='ticket:read'. Event hooks remain empty until F062. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/category.ts server/src/lib/search/indexers/index.ts. -
2026-05-13 — F048 tag indexer. Added
tagIndexerand registered it. The current tag system normalizes unique tag labels intotag_definitions;tags/tag_mappingsare assignment rows, so indexing definitions avoids duplicate result rows for the same tag. The indexer usestag_id/tag_text, links to/msp/tickets?tags={tag_text}, carriestagged_typeandboard_idmetadata when present, and setsrequiredPermission='ticket:read'. Event hooks remain empty until F062. Validation:npx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/tag.ts server/src/lib/search/indexers/index.ts. -
2026-05-13 — F049 client CRUD events.
CLIENT_CREATEDandCLIENT_UPDATEDalready existed inpackages/event-schemasand were already published from both the serverClientServiceand package client actions. Added missingCLIENT_DELETEDevent type + payload schema, published it after successful hard-delete in both delete paths, and addedCLIENT_DELETEDtoclientIndexer.sourceEventsso the future search subscriber can remove the row. Validation:npm -w @alga-psa/event-schemas run typecheckandnpm -w @alga-psa/clients run typecheckpass.npm -w server run typecheckis currently blocked before project files by generated.next/dev/types/routes.d.tssyntax errors (lines 755+); no search/client-service-specific errors were reachable from that run. -
2026-05-13 — F050 contact CRUD events.
CONTACT_CREATED,CONTACT_UPDATED, andCONTACT_ARCHIVEDalready existed inpackages/event-schemasand were already published from the server/package contact create-update paths. Added missingCONTACT_DELETEDevent type + payload schema, publish after successful hard-delete in the packagedeleteContactaction, and addedCONTACT_DELETEDtocontactIndexer.sourceEvents. Validation:npm -w @alga-psa/event-schemas run typecheck,npm -w @alga-psa/clients run typecheck, andnpx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/contact.ts. -
2026-05-13 — F051 user lifecycle events. No app-wide
USER_*CRUD events existed inpackages/event-schemas. AddedUSER_CREATED,USER_UPDATED,USER_DELETED, andUSER_ROLES_UPDATEDwith tenant-scoped payloads. Published them frompackages/users/src/actions/user-actions/userActions.tsafter successful add/update/delete/role-update flows, and registered those events onuserIndexer.sourceEventsso internal team-member rows can refresh/delete when the search subscriber lands. Validation:npm -w @alga-psa/event-schemas run typecheck,npm -w @alga-psa/users run typecheck, andnpx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/user.ts. -
2026-05-13 — F052 project-family events. Existing project/task workflow events covered create/status/assignment but not every indexed row lifecycle. Added
PROJECT_DELETED,PROJECT_PHASE_CREATED/UPDATED/DELETED,PROJECT_TASK_UPDATED/DELETED, andPROJECT_TASK_COMMENT_CREATED/UPDATED/DELETEDschemas. Published the missing events from project phase mutations, project hard-delete, task update/delete/move, and task-comment create/update/delete while preserving existingPROJECT_*,PROJECT_TASK_*, and legacyTASK_COMMENT_*events. Updated source events for the project, phase, task, and task-comment indexers. Validation:npm -w @alga-psa/event-schemas run typecheck,npm -w @alga-psa/projects run typecheck, andnpx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/project.ts server/src/lib/search/indexers/project_phase.ts server/src/lib/search/indexers/project_task.ts server/src/lib/search/indexers/project_task_comment.ts. -
2026-05-13 — F053 asset events.
ASSET_CREATED,ASSET_UPDATED, assignment, unassignment, and warranty events already existed and were published from the package action paths; the API service already attemptedASSET_DELETEDbut the schema did not define it. AddedASSET_DELETEDto the event schema, published it frompackages/assets/src/actions/assetActions.tsafter successful hard-delete, and added it toassetIndexer.sourceEventsso deleted assets can be removed from the search index. Validation:npm -w @alga-psa/event-schemas run typecheck,npm -w @alga-psa/assets run typecheck, andnpx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/asset.ts. -
2026-05-13 — F054 invoice events. Existing invoice workflow events covered generation/finalization/status/delivery, and
InvoiceServicealready published an undeclaredINVOICE_DELETED. Added genericINVOICE_CREATED/UPDATED/DELETED,INVOICE_ITEM_CREATED/UPDATED/DELETED, andINVOICE_ANNOTATION_CREATED/UPDATED/DELETEDschemas. Published header/item events fromserver/src/lib/api/services/InvoiceService.ts, annotation create from the billing invoice model save point, and delete events fromhardDeleteInvoice. Updated invoice, invoice-item, and invoice-annotation indexer source events. Validation:npm -w @alga-psa/event-schemas run typecheck,npm -w @alga-psa/billing run typecheck, andnpx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/invoice.ts server/src/lib/search/indexers/invoice_item.ts server/src/lib/search/indexers/invoice_annotation.ts. A rawnpx tsc ... server/src/lib/api/services/InvoiceService.tsremains blocked by existing repo-wide module-resolution/JSX settings and unrelated pre-existing server errors, so it was not used as the gate. -
2026-05-13 — F055 contract/client-contract events. Existing
CONTRACT_*workflow events were mostly assignment-oriented and client-contract rows had no distinct source events. AddedCONTRACT_DELETEDplusCLIENT_CONTRACT_CREATED/UPDATED/DELETEDschemas, widened contract event schemas with a generic search payload union, published contract CRUD events frompackages/billing/src/actions/contractActions.ts, and published client-contract create/update/deactivate events frompackages/clients/src/actions/clientContractActions.ts. Updated the contract and client-contract indexer source events. Validation:npm -w @alga-psa/event-schemas run typecheck,npm -w @alga-psa/billing run typecheck,npm -w @alga-psa/clients run typecheck, andnpx tsc --noEmit --pretty false --skipLibCheck server/src/lib/search/indexers/contract.ts server/src/lib/search/indexers/client_contract.ts. -
2026-05-13 — F056 document/KB events. Added
DOCUMENT_UPDATEDplusKB_ARTICLE_CREATED/UPDATED/DELETEDschemas.documentIndexer.sourceEventsnow covers uploaded/updated/deleted/generated/associated/detached events, andkbArticleIndexer.sourceEventscovers the KB article CRUD family. PublishedDOCUMENT_UPDATEDfrom document metadata, upload, and BlockNote content create/update/delete paths, andDOCUMENT_DELETEDfrom hard-delete. Existing post-commitDOCUMENT_ASSOCIATED/DOCUMENT_DETACHEDworkflow publishes remain the association insert/delete signal for search re-indexing. Published KB article create/update/delete events from the KB actions. Validation:npm -w @alga-psa/event-schemas run typecheckandnpm -w @alga-psa/documents run typecheck. -
2026-05-13 — F057 service catalog events. Added
SERVICE_CATALOG_CREATED/UPDATED/DELETEDschemas and registered them onserviceCatalogIndexer.sourceEvents. Published the events from bothServiceCatalogServiceandProductCatalogService, because services and products share the sameservice_catalogtable and the v1 indexer indexes every catalog row. Validation:npm -w @alga-psa/event-schemas run typecheckpasses. A raw single-filenpx tscagainst the server service files is still blocked by existing workspace module-resolution/alias settings (@/interfaces/*,@alga-psa/core/*,@alga-psa/event-bus/publishers) rather than by the changed lines. -
2026-05-13 — F058 service-request events. Added
SERVICE_REQUEST_SUBMISSION_CREATED/UPDATED/DELETEDandSERVICE_REQUEST_DEFINITION_CREATED/UPDATED/DELETEDschemas, registered them on the submission/definition indexers, and addedserver/src/lib/service-requests/searchEvents.tsas the shared publish helper. Submission create and execution-status transitions now publish events; definition creation, duplication/template creation, draft saves, publish, archive, and unarchive publish definition events. There is no current hard-delete path for service-request definitions/submissions, so the*_DELETEDevent types are reserved for the future subscriber delete branch. Validation:npm -w @alga-psa/event-schemas run typecheck;git diff --check. -
2026-05-13 — F059 workflow-task events. Added
WORKFLOW_TASK_CREATED/UPDATED/DELETED/ASSIGNMENT_CHANGEDschemas and registered them onworkflowTaskIndexer.sourceEvents. The CE/shared write points areshared/workflow/persistence/workflowTaskModel.tsandshared/task-inbox/taskInboxService.ts, so task create, inline task create, status updates, response updates, and completion now publish workflow-task search events from there. The assignment-change event name is registered for assignment mutators; current CE shared code does not expose a dedicated assignment update method beyond create-time assignees. Validation:npm -w @alga-psa/event-schemas run typecheck,npm -w @alga-psa/shared run typecheck, andgit diff --check. -
2026-05-13 — F060 interaction events. Added
INTERACTION_CREATED/UPDATED/DELETEDschemas, registered them oninteractionIndexer.sourceEvents, and published them frompackages/clients/src/actions/interactionActions.ts. The existingINTERACTION_LOGGEDworkflow event remains for workflow/domain consumers; the new CRUD-shaped events give search a simple upsert/delete contract. Validation:npm -w @alga-psa/event-schemas run typecheck,npm -w @alga-psa/clients run typecheck, andgit diff --check.
Implementation order suggestion (not prescriptive)
-
2026-05-13 — T125 typeahead native new-tab behavior. Added UI contract coverage that result anchors do not install click handlers or call
preventDefault, preserving Cmd/Ctrl-click and middle-click browser behavior. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T126 see-all typeahead row. Added UI contract coverage that the final typeahead row uses the
search.seeAllResultsi18n key, passestotalCount, and links to/msp/search?q=${encodeURIComponent(trimmedQuery)}via a native anchor. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T127 quiet typeahead threshold. Added UI contract coverage that
SearchPaletteonly opens typeahead fortrimmedQuery.length >= 2and clears result state below that threshold, matching the PRD empty-state behavior. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T128 server-rendered search results. Added route contract coverage that
/msp/searchis dynamic, readsqfromsearchParams, awaitssearchAppActionfor non-empty queries, and passes the resolvedinitialResultintoSearchPageClientfor first render. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T129 debounced URL updates. Added results-page UI contract coverage that input changes debounce for 200ms, write
qintoURLSearchParams, callrouter.replace(nextUrl, { scroll: false }), and clear timers on cleanup. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T130 deep-linked search state. Added UI contract coverage that
/msp/searchreadstype,cursor, andsortfromsearchParams, passes them as initial props, andSearchPageClientinitializes local query/type state from those props for cold opens. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T131 filter count badges. Added UI contract coverage that filter chips compute per-type badge counts from
initialResult.groups[type], useinitialResult.totalCountfor All, and expose stable chip IDs. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T132 grouped All results. Added UI contract coverage that the All view builds grouped sections from
initialResult.results, caps each group with.slice(0, 10), and renders rows through the shared result-row renderer. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T133 single-type flat results. Added UI contract coverage that the page passes
types: [activeType]tosearchAppActionfor a selected type and thatSearchPageClientrendersinitialResult.results.map(renderResultRow)as a flat list whenactiveType !== 'all'. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T134 cursor pagination links. Added UI contract coverage that the results page derives previous and next cursor stacks separately, linking previous to the prior boundary and next to
initialResult.nextCursorwith the current boundary appended. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T135 empty search state. Added UI contract coverage that the results page only shows empty state after a non-empty settled query with zero results, echoes
initialQuerythroughsearch.noResults, and offers the clear-filter anchor for filtered empty results. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T136 loading skeleton. Added UI contract coverage that results page detects
query.trim() !== initialQuery, renders fiveanimate-pulseskeleton rows with a translated loading label, and removes that branch once URL-backed results catch up. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T137 native result anchors. Added UI contract coverage that
renderResultRowreturns an<a href={row.url}>with stable row IDs and no click handler orpreventDefault, preserving browser Cmd/Ctrl-click behavior. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T138 recent sort SQL. Added query-layer coverage that
sort: 'recent'orders bysource_updated_at DESC, object_id ASCand does not includescore DESCin theORDER BYclause. Validation:cd server && npx vitest run src/test/unit/searchQuery.test.ts --coverage=false. -
2026-05-13 — T139 sidebar combobox ARIA. Added UI contract coverage that the sidebar input declares combobox/list semantics, reflects
aria-expanded={isOpen}, points toapp-search-typeahead-list, and updatesaria-activedescendantthrough component state. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T140 sidebar arrow navigation. Adjusted
SearchPalettearrow-key state so Down advances into options and wraps back to the input state, while Up from the first option returns to input (activeIndex = -1) instead of jumping to the last row. Added UI contract coverage for that behavior. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T141 sidebar Escape close. Added UI contract coverage that Escape prevents default, clears typeahead state, marks the list dismissed, resets active option state, and does not blur the combobox input. Validation:
cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T142 sidebar Enter submits to results. Added UI contract coverage that Enter prevents default, calls
navigateToActiveOption, and the no-active-row path navigates toseeAllUrl(/msp/search?q=...). Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T143 sidebar Enter opens active row. Added UI contract coverage that the active suggestion path in
navigateToActiveOptioncallswindow.location.assign(visibleResults[activeIndex].url)before falling back to the full-results URL. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T144 stable sidebar input id. Added UI contract coverage that
SearchPalettekeeps the sidebar combobox input ID stable asapp-search-inputfor UI reflection and accessibility tests. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T145 stable result row ids. Added UI contract coverage that both sidebar suggestion metadata and results-page anchors use the shared kebab-case
toDomIdParthelper and theapp-search-result-row-{type}-{id}ID pattern. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T146 stable filter chip ids. Added UI contract coverage that the All filter chip uses
app-search-filter-chip-alland every registered type chip usesapp-search-filter-chip-{toDomIdPart(type)}while iteratingtypeEntries. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T147 English search locale keys. Added
searchI18n.contract.test.tscoverage thatserver/public/locales/en/msp/core.jsoncontains the requiredsearch.*leaves and everySEARCH_OBJECT_TYPESfilter/group label. Validation:cd server && npx vitest run src/test/unit/searchI18n.contract.test.ts --coverage=false. -
2026-05-13 — T148 search locale key completeness. Ran the lang-pack pipeline (
node scripts/generate-pseudo-locales.cjs && node scripts/validate-translations.cjs; zero errors, existing Polish extra-key warnings outside search) and added coverage that every locale'ssearchnamespace has the same leaf-key structure as English. Validation:cd server && npx vitest run src/test/unit/searchI18n.contract.test.ts --coverage=false. -
2026-05-13 — T149 no hardcoded search UI copy. Removed the literal
/msp/searchmetadata title and added a grep-style i18n guard ensuring sidebar/results-page visible search phrases are absent from source while key UI paths uset('search.*'). Validation:cd server && npx vitest run src/test/unit/searchI18n.contract.test.ts src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T150 pseudo-locale search strings. Added coverage that every
xxpseudo-locale search string differs from English and contains the repo's pseudo fill token (11111), which is the current pseudo-locale convention generated byscripts/generate-pseudo-locales.cjs; this guards against untranslated search UI leaks. Validation:cd server && npx vitest run src/test/unit/searchI18n.contract.test.ts --coverage=false. -
2026-05-13 — T151 search live-index env docs. Added deploy contract coverage that
.env.exampledocumentsSEARCH_INDEX_LIVE=falseand Helm exposesserver.searchIndexLive: falsewired into theSEARCH_INDEX_LIVEcontainer env var. Validation:cd server && npx vitest run src/test/unit/searchDeploy.contract.test.ts --coverage=false. -
2026-05-13 — T152 search deploy runbook sequence. Added deploy contract coverage that
docs/deployment/app-wide-search-runbook.mdorders rollout as migrate → deploy withSEARCH_INDEX_LIVE=false→npm run search:backfill→ flipSEARCH_INDEX_LIVE=true/roll server-workers → verifysearch:reconcile. Validation:cd server && npx vitest run src/test/unit/searchDeploy.contract.test.ts --coverage=false. -
2026-05-13 — T153 search count/latency telemetry. Mocked the logger in
searchActions.test.tsand added coverage that a full search emitssearch.query.countandsearch.query.latency_mswith variant, tenant, user id, and numeric latency value. Validation:cd server && npx vitest run src/test/unit/searchActions.test.ts --coverage=false. -
2026-05-13 — T154 empty search telemetry. Added search-action coverage that a full search with zero visible results emits
search.query.emptywith full-search variant, tenant, and user id. Validation:cd server && npx vitest run src/test/unit/searchActions.test.ts --coverage=false. -
2026-05-13 — T155 typeahead rate limit. Added behavioral coverage using the real in-memory limiter: 30 typeahead calls for the same tenant/user resolve, and the 31st rejects with
SearchRateLimitError(429-equivalent). Validation:cd server && npx vitest run src/test/unit/searchActions.test.ts --coverage=false. -
2026-05-13 — T156 full-search rate limit. Added behavioral coverage using the real in-memory limiter: 10 full-search calls for the same tenant/user resolve, and the 11th rejects with
SearchRateLimitError(429-equivalent). Validation:cd server && npx vitest run src/test/unit/searchActions.test.ts --coverage=false. -
2026-05-13 — T157 ticket comment hash anchors. Added hash-anchor contract coverage that
CommentItemdetects#comment-{comment_id}, scrolls the corresponding DOM id into view, applies.search-highlight, and clears it after ~2s. Validation:cd server && npx vitest run src/test/unit/searchHashAnchors.contract.test.ts --coverage=false. -
2026-05-13 — T158 invoice hash anchors. Added line-item and annotation hash-anchor support: invoice redirect preserves hashes,
LineItemhandles#item-{item_id}with scroll/highlight, andInvoiceAnnotationshandles loaded#annotation-{annotation_id}rows similarly. Validation:cd server && npx vitest run src/test/unit/searchHashAnchors.contract.test.ts --coverage=false. -
2026-05-13 — T159 project task comment hash anchors. Added contract coverage that
/msp/projects/{id}/tasks/{taskId}redirects preserve the hash,ProjectPagekeeps#comment-*while task selection is stable, andTaskCommentscrolls/highlights thecomment-{taskCommentId}target. Validation:cd server && npx vitest run src/test/unit/searchHashAnchors.contract.test.ts --coverage=false. -
2026-05-13 — T160 ACME top result acceptance. Added action-level acceptance coverage that a search returning ACME client and ACME ticket hits preserves the client as result #1 and reports grouped counts for both client and ticket. Validation:
cd server && npx vitest run src/test/unit/searchActions.test.ts --coverage=false. -
2026-05-13 — T161 sidebar Enter acceptance. Added UI acceptance coverage that sidebar Enter uses
navigateToActiveOption()and the no-active-row path navigates toseeAllUrl, built as/msp/search?q=${encodeURIComponent(trimmedQuery)}. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T162 result new-tab acceptance. Added UI acceptance coverage that results-page rows are plain
<a href={row.url}>anchors with no click interception, while page state remains URL-backed through initial cursor/sort/query props. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T163 ticket identifier acceptance. Added a metadata identifier prefix branch (
LIKE q.identifier || '%') scored at 900 below exact matches (1000), so shortened IDs such astic-10can findTIC-1023. Added query-layer coverage for the exact and prefix SQL branches. Validation:cd server && npx vitest run src/test/unit/searchQuery.test.ts --coverage=false. -
2026-05-13 — T164 ticket-comment search-to-highlight acceptance. Added contract coverage that
ticketCommentIndexeremits/msp/tickets/{ticket_id}#comment-{comment_id}URLs with markdown-flattened body content and the ticketCommentItemhonors the same hash by scrolling/highlighting. Validation:cd server && npx vitest run src/test/unit/searchHashAnchors.contract.test.ts --coverage=false. -
2026-05-13 — T165 internal-comment positive visibility. Added ACL verifier coverage that an internal user keeps an internal ticket-comment search row when the comment exists and its parent ticket is readable. Validation:
cd server && npx vitest run src/test/unit/searchAcl.test.ts --coverage=false. -
2026-05-13 — T166 internal-comment negative visibility. Added ACL verifier coverage that a non-internal user loses an internal ticket-comment search row and the verifier short-circuits before loading the parent ticket. Validation:
cd server && npx vitest run src/test/unit/searchAcl.test.ts --coverage=false. -
2026-05-13 — T167 project client-scope denial. Added ACL verifier coverage that a project whose
client_idis outsideaccessibleClientIdsis removed from visible search rows. Validation:cd server && npx vitest run src/test/unit/searchAcl.test.ts --coverage=false. -
2026-05-13 — T168 document client-scope denial. Added ACL verifier coverage that a document whose
client_idis outsideaccessibleClientIdsis removed from visible search rows; v1 leaves per-user document sharing out of scope. Validation:cd server && npx vitest run src/test/unit/searchAcl.test.ts --coverage=false. -
2026-05-13 — T169 misspelled Exchange acceptance. Added query-layer acceptance coverage that the pg_trgm fallback path returns
Exchangeas the first hit for misspelledexhcangeand preserves the higher score over weaker fuzzy matches. Validation:cd server && npx vitest run src/test/unit/searchQuery.test.ts --coverage=false. -
2026-05-13 — T170 live ticket-create indexing. Added subscriber acceptance coverage that a
TICKET_CREATEDevent resolves the ticket indexer, loads the ticket document, and callsupsertSearchDocduring the event handler path. Validation:cd server && npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false. -
2026-05-13 — T171 live ticket-delete indexing. Added subscriber acceptance coverage that a
TICKET_DELETEDevent deletes the ticket index row immediately and does not call the ticket indexer'sloadOne. Validation:cd server && npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false. -
2026-05-13 — T172 all-entity backfill acceptance. Added backfill coverage that
runSearchBackfill({ tenant })iterates all 27 registered indexers and upserts one sampled searchable doc per object type. Validation:cd server && npx vitest run src/test/unit/searchBackfill.test.ts --coverage=false. -
2026-05-13 — T173 reconciliation restores missing index row. Added reconciliation coverage that a source doc absent from
app_search_indexis detected by the missing-row phase and upserted back into the index. Validation:cd server && npx vitest run src/test/unit/searchReconcile.test.ts --coverage=false. -
2026-05-13 — T174 generated tenant-isolation load guard. Added query-layer generated-load coverage across 50 tenants that asserts every SQL query includes
s.tenant = ?::uuid, every call binds the requested tenant, and every returned synthetic row belongs to that tenant. Validation:cd server && npx vitest run src/test/unit/searchQuery.test.ts --coverage=false. -
2026-05-13 — T175 pseudo-locale search coverage. Added i18n contract coverage that the
xxpseudo locale has every Englishsearch.*leaf key, includes pseudo fill for each rendered search string, and has no raw English literal text outside interpolation placeholders. Validation:cd server && npx vitest run src/test/unit/searchI18n.contract.test.ts --coverage=false. -
2026-05-13 — T176 keyboard-only search flow. Added UI contract coverage for the full keyboard path: shortcut focus, arrow navigation, Enter/Escape behavior, results-page input URL updates, filter chips, pagination links, and clear-filter control. Validation:
cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T177 cold deep-link restore. Added UI contract coverage that
/msp/searchreadsq,type,cursor, andsortfrom the URL, passes them tosearchAppAction, and hydratesSearchPageClientwith the same state. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T178 Citus/GIN search-plan contract. Added migration/query contract coverage that
app_search_indexis distributed by tenant outside a transaction, has GIN indexes for FTS and trigram predicates, and the query path includes the tenant predicate plus indexable FTS/trigram branches. Validation:cd server && npx vitest run src/test/unit/searchMigration.contract.test.ts --coverage=false. -
2026-05-13 — T179 Citus upsert locality. Added upsert SQL coverage that tenant is the first UUID-bound insert value and part of the conflict target
(tenant, object_type, object_id), keeping single-doc writes shard-local under Citus. Validation:cd server && npx vitest run src/test/unit/searchUpsert.test.ts --coverage=false. -
2026-05-13 — T180 mandatory tenant predicate. Added query SQL capture coverage that every search emits
WHERE s.tenant = ?::uuidand binds the authenticated tenant in the expected parameter slot. Validation:cd server && npx vitest run src/test/unit/searchQuery.test.ts --coverage=false. -
2026-05-13 — T181 large document data-URI fixture. Added document-indexer coverage for a 10MB BlockNote payload with embedded image data URI: visible text remains,
data:imageis stripped, and indexed body stays ≤64KB. Validation:cd server && npx vitest run src/test/unit/searchIndexers.test.ts --coverage=false. -
2026-05-13 — T182 service-request secret payload fixture. Added service-request-submission indexer coverage that visible payload strings are indexed while
password,api_key, andauthorizationvalues are excluded from the body. Validation:cd server && npx vitest run src/test/unit/searchIndexers.test.ts --coverage=false. -
2026-05-13 — T183 process_large_lexemes SQL path. Added upsert SQL capture coverage that search-vector computation calls
public.process_large_lexemes(?)for title, subtitle, and body weight tiers A/B/C. Validation:cd server && npx vitest run src/test/unit/searchUpsert.test.ts --coverage=false. -
2026-05-13 — T184 backfill-to-live smoke. Added subscriber/backfill smoke coverage that a seed tenant is backfilled first, then
SEARCH_INDEX_LIVE=trueallows aTICKET_CREATEDevent to land as an incremental search upsert. Validation:cd server && npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false. -
2026-05-13 — T185 ticket-update subscriber stress. Added bounded stress coverage that processes 100
TICKET_UPDATEDevents through the subscriber path, upserts each ticket, cascades the comment reindex lookup, and completes well under the 30s lag budget. Validation:cd server && npx vitest run src/test/unit/searchIndexSubscriber.behavior.test.ts --coverage=false. -
2026-05-13 — T186 mixed identifier/free-text ranking. Expanded query parsing to extract identifier-like tokens from mixed queries (e.g.
TIC-1023 vpn) and added coverage that the exact identifier row is pinned above regular free-text matches. Validation:cd server && npx vitest run src/test/unit/searchQuery.test.ts --coverage=false. -
2026-05-13 — T187 permission-prefiltered type union. Added action-layer permission-to-object-type filtering before query execution, so a user with only
client:readcannot query ticket/document types even when requested. Validation:cd server && npx vitest run src/test/unit/searchActions.test.ts --coverage=false. -
2026-05-13 — T188 malformed cursor typed error. Added action-layer coverage that a malformed cursor error from the query layer is propagated with
code='invalid_cursor'rather than being wrapped as a generic failure. Validation:cd server && npx vitest run src/test/unit/searchActions.test.ts --coverage=false. -
2026-05-13 — T189 ticket-comment renamed-parent subtitle. Updated ticket-comment indexing to include the parent ticket title in the subtitle alongside the ticket number, and added coverage that a renamed parent title appears in both title and subtitle after reindex. Validation:
cd server && npx vitest run src/test/unit/searchIndexers.test.ts --coverage=false. -
2026-05-13 — T190 visible-user document ACL refresh. Added visible-user reindex job coverage for a document row currently containing a user in
visible_to_user_ids: the job finds it via?::uuid = ANY(visible_to_user_ids), reloads the document indexer, and upserts the refreshed ACL row. CE v1 still has no internal document share-list source table; this validates the async refresh path for future/private rows. Validation:cd server && npx vitest run src/test/unit/searchVisibleUserReindex.test.ts --coverage=false. -
2026-05-13 — T191 SearchPalette accessibility contract. No axe harness is installed, so added static accessibility coverage for the serious/critical surfaces: combobox ARIA state, listbox linkage, translated labels, decorative icon hiding, native anchors, and no forced focus removal. Validation:
cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T192 results-page accessibility contract. Added static accessibility coverage for
/msp/search: named region, labeled filter/sort/loading/pagination regions, hidden decorative icons, native anchors, and no fake button roles. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T193 full-search latency guard. Added a mocked medium-tenant full-search benchmark over 20 unique users to avoid rate-limit interference and assert action p95 remains <500ms while telemetry still records latency. Validation:
cd server && npx vitest run src/test/unit/searchActions.test.ts --coverage=false. -
2026-05-13 — T194 zero ACL drift happy path. Added broader verifier coverage that ticket and document rows whose SQL-level ACL agrees with record-level checks are returned without emitting
search.acl_drifttelemetry. Validation:cd server && npx vitest run src/test/unit/searchAcl.test.ts --coverage=false. -
2026-05-13 — T195 reconciliation summary log. Added reconciliation contract coverage that the handler logs per-tenant/per-object-type summary fields for reindexed, stale-deleted, and missing-inserted rows. Validation:
cd server && npx vitest run src/test/unit/searchReconcile.test.ts --coverage=false. -
2026-05-13 — T196 client-contract joined indexing. Added client-contract indexer coverage for joins to
clientsandcontracts, derived{client_name} – {contract_name}title, dates/status body, canonical client contract URL, andclientScopeId = client_id. Validation:cd server && npx vitest run src/test/unit/searchIndexers.test.ts --coverage=false. -
2026-05-13 — T197 interaction BlockNote notes flattening. Added interaction indexer coverage for BlockNote JSON notes: text leaves such as
Added Sciton Tribrid Laserare indexed, JSON syntax is absent, subtitle includes type/client/contact/ticket context, andinteraction:readis required. Validation:cd server && npx vitest run src/test/unit/searchIndexers.test.ts --coverage=false. -
2026-05-13 — T198 workflow-task tenant filter with single-column PK. Added workflow-task indexer coverage that
loadOnequeriesworkflow_taskswithwhere('tenant', tenant)plusandWhere('task_id', id), despitetask_idbeing the only PK column. Validation:cd server && npx vitest run src/test/unit/searchIndexers.test.ts --coverage=false. -
2026-05-13 — T199 workflow-task assigned_users JSONB parsing. Added workflow-task indexer coverage that JSON-string
assigned_usersentries (user_idandid) are parsed into dedupedacl.visibleToUserIds, which upsert writes intovisible_to_user_ids. Validation:cd server && npx vitest run src/test/unit/searchIndexers.test.ts --coverage=false. -
2026-05-13 — T200 CE eeIndexers stub. Added registry coverage that
@ee/lib/search/indexersresolves to the CE stub[], andallIndexers().length === ceIndexers.length === 27. Validation:cd server && npx vitest run src/test/unit/searchRegistry.test.ts --coverage=false. -
2026-05-13 — T201 dynamic registry extension. Changed search registry accessors to rebuild from
ceIndexers + eeIndexerson each call, then added coverage that a synthetic indexer pushed intoceIndexersappears inallIndexers(),registeredObjectTypes(), andgetIndexer('synthetic'). Validation:cd server && npx vitest run src/test/unit/searchRegistry.test.ts --coverage=false. -
2026-05-13 — T202 EE orphan query safety. Added action coverage that CE query type lists are derived from registered object types and do not include an unregistered
ee_chat_historyorphan type. Validation:cd server && npx vitest run src/test/unit/searchActions.test.ts --coverage=false. -
2026-05-13 — T203 registry-driven filter chips. Added UI contract coverage that the search page receives
registeredObjectTypes(), buildstypeEntriesfromregisteredTypes, renders theAllchip separately, and maps one chip per registered type. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T204 missing-label fallback. Added UI contract coverage that search filter and group labels pass
defaultValue: humanizeObjectType(type)through i18n, withservice_request_submissionfalling back toService request submissionwhen a locale key is absent. Validation:cd server && npx vitest run src/test/unit/searchUi.contract.test.ts --coverage=false. -
2026-05-13 — T205 reconciliation orphan safety. Added reconciliation coverage that a requested unregistered object type is resolved through
getIndexer(data.type), logs a skip, returns an empty indexer list, and therefore does not attempt source loading or mutation for orphan rows such asee_chat_history. Validation:cd server && npx vitest run src/test/unit/searchReconcile.test.ts --coverage=false.
Roughly:
- Migration + indexes (F001–F008).
- Types, normalize utilities, registry skeleton (F009–F021).
- One indexer end-to-end as a vertical slice: clients (F022) → upsert → query → typeahead → see results in dev.
- Remaining 26 indexers (F023–F048) in parallel-friendly batches.
- Event publishes that don't exist yet (F049–F062). Many of these are 5–15 line additions at existing action sites.
- Subscriber (F063–F067) + cascades (F068–F072).
- Backfill CLI (F073–F078).
- Reconciliation (F079–F083).
- Query builder + ACL + snippets (F084–F099).
- Server actions (F100–F103).
- UI (F104–F117) + a11y/i18n (F118–F123).
- Telemetry, deploy notes, hash-anchor scroll (F124–F130).