Shared data-access layer for SpaceCat services. Provides entity models/collections backed by PostgreSQL via PostgREST.
Lambda/ECS service
-> this package (@adobe/spacecat-shared-data-access)
-> @supabase/postgrest-js
-> mysticat-data-service (PostgREST + Aurora PostgreSQL)
- Database schema: lives in mysticat-data-service as dbmate SQL migrations
- This package: JavaScript model/collection layer mapping camelCase entities to snake_case PostgREST API
- v2 (retired): ElectroDB -> DynamoDB. Published as
@adobe/spacecat-shared-data-access-v2 - v3 (current): PostgREST client -> mysticat-data-service
| File | Purpose |
|---|---|
src/index.js |
Default export: dataAccessWrapper(fn) for Helix/Lambda handlers |
src/service/index.js |
createDataAccess(config, log?, client?) factory — returns entity collections + services.postgrestClient |
src/service/index.d.ts |
DataAccess and DataAccessServices type definitions |
src/models/base/schema.builder.js |
DSL for defining entity schemas (attributes, references, indexes) |
src/models/base/base.model.js |
Base entity class (auto-generated getters/setters, save, remove) |
src/models/base/base.collection.js |
Base collection class (findById, all, query, count) |
src/models/base/entity.registry.js |
Registers all entity collections |
src/util/postgrest.utils.js |
camelCase<->snake_case field mapping, query builders, cursor pagination |
src/util/url-index.utils.js |
Source-URL index (opportunity_urls/suggestion_urls) writer/reader helpers |
src/models/index.js |
Barrel export of all entity models |
Each entity lives in src/models/<entity>/ with 4 files:
src/models/site/
site.schema.js # SchemaBuilder definition (attributes, references, indexes)
site.model.js # Extends BaseModel (business logic, constants)
site.collection.js # Extends BaseCollection (custom queries)
index.js # Re-exports model, collection, schema
const schema = new SchemaBuilder(Site, SiteCollection)
.addReference('belongs_to', 'Organization') // FK -> organizations.id
.addReference('has_many', 'Audits') // One-to-many relationship
.addAttribute('baseURL', {
type: 'string',
required: true,
validate: (value) => isValidUrl(value),
})
.addAttribute('deliveryType', {
type: Object.values(Site.DELIVERY_TYPES), // Enum validation
default: Site.DEFAULT_DELIVERY_TYPE,
required: true,
})
.addAttribute('config', {
type: 'any',
default: DEFAULT_CONFIG,
get: (value) => Config(value), // Transform on read
})
.addAllIndex(['imsOrgId']) // Query index
.build();| Option | Purpose |
|---|---|
type |
'string', 'number', 'boolean', 'any', 'map', or array of enum values |
required |
Validation on save |
default |
Default value or factory function |
validate |
Custom validation function |
readOnly |
No setter generated |
postgrestField |
Custom DB column name (default: camelToSnake(name)) |
postgrestIgnore |
Virtual attribute, not sent to DB |
hidden |
Excluded from toJSON() |
watch |
Array of field names that trigger this attribute's setter |
set |
Custom setter (value, allAttrs) => transformedValue |
get |
Custom getter (value) => transformedValue |
Models use camelCase, database uses snake_case. Mapping is automatic:
| Model field | DB column | Notes |
|---|---|---|
siteId (idName) |
id |
Primary key always maps to id |
baseURL |
base_url |
Auto camelToSnake |
organizationId |
organization_id |
FK from belongs_to reference |
isLive |
is_live |
Auto camelToSnake |
Override with postgrestField: 'custom_name' on the attribute.
Changes require two repos:
1. Database schema — mysticat-data-service
make migrate-new name=add_foo_to_sites
# Edit the migration SQL (table, columns, indexes, grants, comments)
make migrate && make testEvery migration must include: indexes on FKs, GRANT to postgrest_anon/postgrest_writer, COMMENT ON for OpenAPI docs. See mysticat-data-service CLAUDE.md.
- Add attribute in
<entity>.schema.js-> auto-generates getter/setter - Add business logic in
<entity>.model.js - Add custom queries in
<entity>.collection.js - New entity: create 4 files + register in
src/models/index.js
npm run test:it # Spins up PostgREST via Docker, runs mocha suitenpm test # Unit tests (mocha + sinon + chai)
npm run test:debug # Unit tests with debugger
npm run test:it # Integration tests (Docker: Postgres + PostgREST)
npm run lint # ESLint
npm run lint:fix # Auto-fix lint issuesIntegration tests pull the mysticat-data-service Docker image from ECR:
# ECR login (one-time)
aws ecr get-login-password --profile spacecat-dev --region us-east-1 \
| docker login --username AWS --password-stdin 682033462621.dkr.ecr.us-east-1.amazonaws.com
# Override image tag
export MYSTICAT_DATA_SERVICE_TAG=v1.13.0
npm run test:it- Tests in
test/unit/models/<entity>/ - PostgREST calls are stubbed via sinon
- Each entity model and collection has its own test file
dataAccess.services.postgrestClient exposes the raw @supabase/postgrest-js PostgrestClient for querying tables that don't have entity models (e.g. analytics views, reporting tables):
const { postgrestClient } = context.dataAccess.services;
const { data, error } = await postgrestClient
.from('brand_presence_executions')
.select('*')
.eq('site_id', siteId)
.limit(100);This is the same client instance used internally by entity collections — same URL, auth, and schema.
The opportunity_urls / suggestion_urls tables are pointer tables mapping a canonical source
URL to the opportunity/suggestion it backs (a reverse URL → entity lookup). Do not hand-roll
PostgREST queries against them — use the shared helpers, re-exported from the package root
(@adobe/spacecat-shared-data-access):
syncUrlIndex(postgrestClient, { table, siteId, entityId, entityType, urls })— full-replace of an entity's URLs. Upserts the canonical set, then prunes stale rows (paginated read-back + chunked deletes). An emptyurlsarray clears the entity; a non-empty array that canonicalizes to nothing throws. Requires thepostgrest_writerrole (POSTGREST_API_KEYset).syncUrlIndexMany(postgrestClient, { table, siteId, entityType, entries })batchessyncUrlIndexover many entities in one table (entries: [{ entityId, urls }], all under onesiteIdandentityType): one bulk upsert, one batched read-back, per-entity prune. Collapses the per-entity write fan-out (use it instead ofPromise.all(map(syncUrlIndex))). ReturnsMap<entityId, count>; same semantics and caveats assyncUrlIndex.lookupEntityIdsByUrl(postgrestClient, { table, siteId, urls })— site-scoped reverse lookup returning{ entity_id, entity_type, url }, chunked over.in()and range-paginated per chunk (so a hot URL backing many entities is never silently truncated).URL_INDEX_TABLES— theopportunity_urls/suggestion_urlsallow-list.URL_CHUNK_SIZE— the per-request batch size (50); exported for consumers that batch manually.
Contracts to preserve:
- Canonicalization is a persisted format. Both sides use
canonicalizeUrlfrom@adobe/spacecat-shared-utils; changing it desyncs stored rows from new lookups and requires a re-sync. Keep the writer and reader services on the same version. - Single writer per
entityId.syncUrlIndexis not linearizable across its separate PostgREST calls; concurrent syncs of the same entity can race. A fully atomic replace needs a server-side RPC inmysticat-data-service. - Site scoping. Reads/writes are additionally filtered by
site_id, but the upsert conflict target isentity_id,url; fully preventing a cross-site row flip also needssite_idin the DB uniqueness constraint (lives inmysticat-data-service, along with thepostgrest_writerDELETE/UPDATE grants — the write path breaks without them).
// In a collection method
async findByStatus(status) {
return this.all(
(attrs, op) => op.eq(attrs.status, status),
{ limit: 100, orderBy: { attribute: 'createdAt', direction: 'desc' } }
);
}// belongs_to: site.getOrganization() -> fetches parent org
// has_many: organization.getSites() -> fetches child sites
const site = await dataAccess.Site.findById(id);
const org = await site.getOrganization();
const audits = await site.getAudits();eq, ne, gt, gte, lt, lte, is, in, contains, like, ilike
// Usage in collection.all()
const liveSites = await dataAccess.Site.all(
(attrs, op) => op.eq(attrs.isLive, true)
);| Variable | Required | Purpose |
|---|---|---|
POSTGREST_URL |
Yes | PostgREST base URL (e.g. http://data-svc.internal) |
POSTGREST_SCHEMA |
No | Schema name (default: public) |
POSTGREST_API_KEY |
No | JWT for postgrest_writer role (enables UPDATE/DELETE) |
S3_CONFIG_BUCKET |
No | Only for Configuration entity |
AWS_REGION |
No | Only for Configuration entity |
STATUS_TRANSITION_ENFORCEMENT |
No | Status-transition guard mode: off | warn | enforce. Default warn. See "Status Transition Lifecycle". |
Suggestion.status and FixEntity.status are governed by a transition guard
(canonical design: ADR adobe/mysticat-architecture#174). The guard runs on the
setStatus override of both models — so every writer (api-service single
PATCH, SuggestionCollection.bulkUpdateStatus, autofix-worker) is checked at one
chokepoint.
Enforcement is env-controlled via STATUS_TRANSITION_ENFORCEMENT, read at call time:
| Mode | Behavior |
|---|---|
off |
no check |
warn (default) |
logs a violation (<Entity> <id> <from> -> <to>) and still applies the change — byte-equivalent to the old setter |
enforce |
throws ValidationError on a disallowed transition |
Rollout intent: ship warn to surface today's illegal transitions in logs for
~1–2 weeks, then flip to enforce. A no-op (from === to) always passes.
APIs (exported from the package root):
setStatus(value)/transitionStatus(to)onSuggestionandFixEntity— both guard the transition;transitionStatusis the intention-revealing alias for new code.isAllowedFixTransition(from, to)/isAllowedSuggestionTransition(from, to)+ theFIX_ENTITY_TRANSITIONS/SUGGESTION_TRANSITIONStables (a null/undefinedfrommeans entity creation).deriveSuggestionStatus(outcomes, issues = [], currentStatus = null)— derives a Suggestion status from a per-suggestion list of outcome signals. Each entry is a FixEntity,{status}, or a status string from either vocabulary: FixEntity statuses (DEPLOYED/PUBLISHED→FIXED,FAILED→ERROR,PENDING→IN_PROGRESS,ROLLED_BACK/REJECTED→SKIPPED) or explicit Suggestion-status outcomes a handler asserts for no-fix cases (e.g. consciously skipped →'SKIPPED', not-actionable →'NEW'). Signals are classified viaclassifyStatusand collapsed first-match-wins by severity: ERROR > IN_PROGRESS > FIXED > SKIPPED; all-NEUTRAL (e.g. onlyNEW) →NEW; no recognized signals →currentStatus(default null, so a derive call never clobbers a status set elsewhere). Throws if a non-emptyissuesarray is passed — the CWV multi-issue bubble-up is deferred (SITES-47285): the per-issue codefix vocabulary (PATCH_*/GUIDANCE_GENERATED, written by mystique into the overloadedissue.statusfield) is not yet reconciled with the JS layer.classifyStatus(token)— maps a fix- or suggestion-status token to its severity class (ERROR/IN_PROGRESS/FIXED/SKIPPED/NEUTRAL) ornull. The shared building block behind the bubble-up; consumers building custom collapses should reuse it rather than re-encode the mapping.
Notes: the FixEntity table is the canonical one from the ADR; the Suggestion
table is intentionally permissive in V1 (tune it from the warn-log findings
before enforcing). FixEntity.REJECTED is omitted (not in FixEntity.STATUSES).
Consumer adoption (bump + route writes + warn→enforce) is tracked in SITES-47286.
FixEntity.changeDetails has a canonical v2 shape — the structured,
validated "deploy action" record (who/when/what/which-pages/result) from ADR
adobe/mysticat-architecture#200. It is defined in
src/models/fix-entity/change-details.schema.js (Joi) and wired into the
changeDetails attribute validator.
- Reader-tolerant / additive. The DB column stays
type: 'any'— no migration. Records withoutschemaVersion: 2are legacy freeform (v1) and keep the old non-empty-object guard; onlyschemaVersion: 2records are schema-validated. This runs on the create path (#validateItem→collection.create), not onsave()updates. - Shape.
{ schemaVersion: 2, surface, actorType, target, result? }.target= the proposal (intent):changeType+changes[{ targetPath, property, intendedValue }].result= the outcome:callStatus(success|no_op|client_error|server_error|timeout),applied(enumALL|PARTIAL|NONE, not a boolean),changeResults[](key-matched totarget.changesby(targetPath, property)),pre/postVerifyverdicts, anddeployResponsePayload(≤ 4 KB cap) +deployResponseSha256(hex). - APIs (exported from the package root):
validateChangeDetails(value)— the attribute validator;falseon a non-object,truefor legacy/valid-v2, throws with a descriptive message on an invalid v2 record.changeDetailsV2Schema— the raw JoiObjectSchema(strict; unknown keys rejected) for consumers that want to validate directly.FixEntity.CHANGE_DETAILS— the enum bundle (SURFACES,ACTOR_TYPES,CALL_STATUSES,APPLIED,CHANGE_RESULT_STATUSES,VERIFY_VERDICTS,SCHEMA_VERSION,DEPLOY_RESPONSE_PAYLOAD_MAX_BYTES). Kept on the model (not the barrel) to avoid collisions on these generic names.
- Portable JSON Schema artifact (for the Python runtime, mystique). The Joi
schema is the single source of truth; a JSON Schema is derived from it and
shipped at
schemas/fix-entity-change-details.v2.schema.json(draft-2019-09). Regenerate withnpm run generate:schemas(script:scripts/generate-change-details-schema.js, usesjoi-to-json); a unit test fails CI if the committed artifact drifts from the Joi schema. mystique vendors this file and validates against it. Caveat: JSON Schema captures structure/enums/required/patterns only — the cross-field invariants (changeResults↔target.changeskey-match,applied:ALLcompleteness,deployResponsePayload4 KB cap) are Joi.custom()rules and are NOT in the artifact; a consumer needing them must port those checks. - Not yet done (tracked on SITES-47997): the
publishedBycolumn +executedBy/At→deployedBy/Atrename (dual-write, needs a companion mysticat-data-service migration), and gating theDEPLOYED/PUBLISHEDtransition onresultpresence (extends the SITES-47091 guard).
Site configuration lives in src/models/site/config.js and defines the available import types, their validation schemas, and default configs. This is one of the most frequently changed files in the package.
Update three locations in src/models/site/config.js:
1. Add the constant to IMPORT_TYPES:
export const IMPORT_TYPES = {
// ... existing types
MY_NEW_IMPORT: 'my-new-import',
};2. Add the Joi validation schema to IMPORT_TYPE_SCHEMAS:
export const IMPORT_TYPE_SCHEMAS = {
// ... existing schemas
[IMPORT_TYPES.MY_NEW_IMPORT]: Joi.object({
type: Joi.string().valid(IMPORT_TYPES.MY_NEW_IMPORT).required(),
...IMPORT_BASE_KEYS,
// Add optional type-specific fields here (e.g., geo, limit, year, week)
}),
};IMPORT_BASE_KEYS includes enabled, destinations, and sources. Only add extra fields if the import type needs them.
3. Add the default config to DEFAULT_IMPORT_CONFIGS:
export const DEFAULT_IMPORT_CONFIGS = {
// ... existing defaults
'my-new-import': {
type: 'my-new-import',
destinations: ['default'],
sources: ['rum'], // or 'ahrefs', 'google'
enabled: true,
},
};In test/unit/models/site/config.test.js, add:
- Enable import test — verify
config.enableImport('my-new-import')produces the expected default config - Validation error string — the long
.to.equal(...)assertion for invalid import type errors must include the new type - Validation error details — the
.to.eql([...])array of expected error detail objects must include the new type
| Source | Constant | Used by |
|---|---|---|
'ahrefs' |
IMPORT_SOURCES.AHREFS |
Keyword, traffic, backlink imports |
'google' |
IMPORT_SOURCES.GSC |
Google Search Console imports |
'rum' |
IMPORT_SOURCES.RUM |
RUM/CWV/engagement imports |
- Configuration: S3-backed (not PostgREST). Requires
S3_CONFIG_BUCKET. - KeyEvent: Deprecated in v3. All methods throw.
- LatestAudit: Virtual entity computed from
Auditqueries (no dedicated table).