@murumets-ee/entity 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/cursor.ts","../../src/dto-shaper.ts","../../src/query/client.ts"],"sourcesContent":["/**\n * Cursor-based (keyset) pagination utilities.\n *\n * Cursor pagination avoids the performance cliff of OFFSET at scale (1M+ rows).\n * Instead of `OFFSET N`, it uses a WHERE condition:\n * `WHERE (sortField < lastValue) OR (sortField = lastValue AND id < lastId)`\n * which Postgres can serve from an index in constant time.\n *\n * The cursor is opaque to the client — base64-encoded JSON.\n *\n * Security:\n * - `field` must be whitelisted against the entity's actual fields\n * - `id` must be a valid UUID\n * - `value` is parameterized (never interpolated into SQL)\n * - Malformed cursors return null (caller returns 400)\n */\n\nimport { and, eq, gt, lt, or, type SQL } from 'drizzle-orm'\nimport type { PgTableWithColumns } from 'drizzle-orm/pg-core'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Cursor input for keyset pagination. */\nexport interface CursorInput {\n /** Sort field name (e.g. 'createdAt'). Must be a real column on the entity. */\n field: string\n /** Last seen value of the sort field. */\n value: string | number\n /** Sort direction — must match the ORDER BY direction. */\n direction: 'asc' | 'desc'\n /** Tie-breaker: last seen entity ID. Required for non-unique sort fields. */\n id?: string\n}\n\n/** Decoded cursor (internal, after validation). */\ninterface DecodedCursor {\n field: string\n value: string | number\n direction: 'asc' | 'desc'\n id?: string\n}\n\n// ---------------------------------------------------------------------------\n// UUID validation (same format used throughout the toolkit)\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n// ---------------------------------------------------------------------------\n// Encode / decode\n// ---------------------------------------------------------------------------\n\n/**\n * Encode a cursor for API transport (base64url).\n * Built from the last item in a result set.\n */\nexport function encodeCursor(\n item: Record<string, unknown>,\n sortField: string,\n direction: 'asc' | 'desc',\n): string {\n const payload: CursorInput = {\n field: sortField,\n value: item[sortField] as string | number,\n direction,\n id: item.id as string | undefined,\n }\n return btoa(JSON.stringify(payload))\n}\n\n/**\n * Decode and validate a cursor string from query params.\n * Returns null if the cursor is malformed, tampered, or invalid.\n *\n * Security: the `field` value is NOT validated here — the caller must\n * whitelist it against the entity's actual columns.\n */\nexport function decodeCursor(encoded: string): DecodedCursor | null {\n try {\n const json = atob(encoded)\n const parsed: unknown = JSON.parse(json)\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n\n // Validate field\n if (typeof obj.field !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(obj.field)) {\n return null\n }\n\n // Validate value (string or number)\n if (typeof obj.value !== 'string' && typeof obj.value !== 'number') {\n return null\n }\n\n // Validate direction\n if (obj.direction !== 'asc' && obj.direction !== 'desc') {\n return null\n }\n\n // Validate id (optional, must be UUID if present)\n if (obj.id !== undefined) {\n if (typeof obj.id !== 'string' || !UUID_RE.test(obj.id)) {\n return null\n }\n }\n\n return {\n field: obj.field,\n value: obj.value,\n direction: obj.direction,\n id: obj.id as string | undefined,\n }\n } catch {\n return null\n }\n}\n\n// ---------------------------------------------------------------------------\n// SQL condition builder\n// ---------------------------------------------------------------------------\n\n/**\n * Build the keyset WHERE condition from a decoded cursor.\n *\n * For DESC: `WHERE (field < value) OR (field = value AND id < cursorId)`\n * For ASC: `WHERE (field > value) OR (field = value AND id > cursorId)`\n *\n * The caller must verify that `cursor.field` exists on the table before calling.\n *\n * @param table - Drizzle table with columns\n * @param cursor - Decoded and validated cursor\n * @returns SQL condition, or null if the field doesn't exist on the table\n */\nexport function buildCursorCondition(\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns require PgTableWithColumns<any> for property access\n table: PgTableWithColumns<any>,\n cursor: DecodedCursor,\n): SQL | null {\n const column = table[cursor.field]\n if (!column) return null\n\n const isDesc = cursor.direction === 'desc'\n const compare = isDesc ? lt : gt\n\n // Primary condition: sort field passes the cursor value\n const fieldCondition = compare(column, cursor.value)\n\n // Without tie-breaker ID, use simple comparison\n if (!cursor.id) {\n return fieldCondition\n }\n\n // With tie-breaker: (field < value) OR (field = value AND id < cursorId)\n const idColumn = table.id\n if (!idColumn) return fieldCondition\n\n const idCondition = compare(idColumn, cursor.id)\n return or(fieldCondition, and(eq(column, cursor.value), idCondition))!\n}\n","/**\n * DTO Shaper\n * Transforms raw DB rows into clean DTOs.\n * All fields are real columns — just read from row directly.\n */\n\nimport type { Entity } from './define-entity.js'\nimport type { FieldConfig } from './fields/base.js'\nimport type { InferEntityDTO } from './types/infer.js'\n\nexport interface ShapeDtoOptions {\n select?: string[] // Explicit field selection\n includeInternal?: boolean // Include _version, _scopeId\n}\n\n/**\n * Shape a raw DB row into a typed DTO.\n * Every field is a real column — read directly from row.\n */\nexport function shapeDto<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n row: Record<string, unknown>,\n options?: ShapeDtoOptions,\n): InferEntityDTO<AllFields> | null {\n if (!row) return null\n\n const result: Record<string, unknown> = {}\n const fieldsToInclude = options?.select || Object.keys(entity.allFields)\n\n for (const fieldName of fieldsToInclude) {\n if (!options?.includeInternal && fieldName.startsWith('_')) continue\n\n const fieldConfig = (entity.allFields as Record<string, FieldConfig>)[fieldName]\n if (!fieldConfig) continue\n\n // Blocks are loaded separately from layout tables\n if (fieldConfig.type === 'blocks') continue\n\n result[fieldName] = row[fieldName]\n }\n\n return result as InferEntityDTO<AllFields>\n}\n\n/**\n * Shape multiple rows\n */\nexport function shapeDtos<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n rows: Record<string, unknown>[],\n options?: ShapeDtoOptions,\n): (InferEntityDTO<AllFields> | null)[] {\n return rows.map((row) => shapeDto(entity, row, options))\n}\n","/**\n * QueryClient - Read-only client for frontend use\n * SAFE for client bundles - NO 'server-only' import\n */\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { and, eq, inArray, isNull, or, type SQL, sql } from 'drizzle-orm'\nimport type { PgTableWithColumns } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { CountCacheLike } from '../count-cache.js'\nimport type { CursorInput } from '../cursor.js'\nimport { buildCursorCondition } from '../cursor.js'\nimport type { Entity } from '../define-entity.js'\nimport { shapeDto, shapeDtos } from '../dto-shaper.js'\nimport type { FieldConfig } from '../fields/base.js'\nimport type { InferEntityDTO } from '../types/infer.js'\nimport type { Logger } from '../types/logger.js'\n\nexport interface QueryClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n entity: Entity<AllFields>\n db: PostgresJsDatabase // Read-only connection (enforced at PostgreSQL level)\n logger?: Logger\n /** Optional count cache for COUNT(*) query optimization. */\n countCache?: CountCacheLike\n}\n\nexport interface FindByIdOptions {\n select?: string[]\n locale?: string\n /** Default content locale. For localized blocks, NULL rows (from initial create)\n * are only returned as fallback when locale matches defaultLocale. */\n defaultLocale?: string\n}\n\nexport interface FindManyOptions {\n where?: SQL | undefined\n limit?: number\n offset?: number\n orderBy?: SQL | SQL[]\n select?: string[]\n locale?: string\n defaultLocale?: string\n /**\n * Cursor-based (keyset) pagination. When provided, replaces OFFSET with a\n * WHERE condition for O(1) page access at any depth. The `offset` option\n * is ignored when `cursor` is set.\n *\n * The cursor `field` must be a real column on the entity table.\n */\n cursor?: CursorInput\n}\n\nexport interface CountOptions {\n where?: SQL | undefined\n}\n\n/**\n * QueryClient - Read-only entity access for frontends\n *\n * Security:\n * - NO 'server-only' import (safe for client bundles)\n * - Uses read-only DB connection (PostgreSQL enforces with default_transaction_read_only=on)\n * - NO mutation methods on TypeScript type (create/update/delete don't exist)\n * - Auto-filters to published entities if entity has publishable() behavior\n *\n * Phase 1: Core read operations + publishable filtering\n * Phase 2 (TODO): Translation merging, reference population\n *\n * @typeParam AllFields - The entity's complete field map. Inferred automatically\n * when constructing via `createQueryClient(entity)`.\n */\nexport class QueryClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private entity: Entity<AllFields>\n private db: PostgresJsDatabase\n private logger?: Logger\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns require PgTableWithColumns<any> for property access\n private table: PgTableWithColumns<any>\n private isPublishable: boolean\n private countCache?: CountCacheLike\n\n constructor(config: QueryClientConfig<AllFields>) {\n this.entity = config.entity\n this.db = config.db // Assumes read-only connection passed from outside\n this.logger = config.logger\n this.countCache = config.countCache\n\n // Detect if entity has publishable behavior\n this.isPublishable = config.entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n\n // Get table from schema registry\n const table = schemaRegistry.get(config.entity.name)\n if (!table) {\n throw new Error(\n `Schema for entity '${config.entity.name}' not found in registry. ` +\n 'Ensure schemas are generated and registered before creating QueryClient.',\n )\n }\n this.table = table\n }\n\n /**\n * Find entity by ID\n * Auto-filters to published if entity has publishable() behavior\n * PHASE 3: Merges translations if locale is provided\n */\n async findById(id: string, options?: FindByIdOptions): Promise<InferEntityDTO<AllFields> | null> {\n this.logger?.info(\n { entity: this.entity.name, id, locale: options?.locale },\n 'Query: Finding entity by ID',\n )\n\n // Query database\n const whereConditions = [eq(this.table.id, id)]\n\n // Auto-filter to published if entity has publishable behavior\n if (this.isPublishable) {\n whereConditions.push(this.buildPublishFilter(options?.locale))\n }\n\n const [row] = await this.db\n .select()\n .from(this.table)\n .where(and(...whereConditions))\n\n if (!row) return null\n\n // Shape DTO (row is non-null here, so shaped result will be non-null)\n const shaped = shapeDto(this.entity, row, { select: options?.select }) as Record<\n string,\n unknown\n >\n if (!shaped) return null\n\n // Attach blocks from layout table (with locale for block translations)\n if (this.getBlocksFields().length > 0) {\n const blocksMap = await this.loadBlocks([shaped.id as string], options?.locale, {\n defaultLocale: options?.defaultLocale,\n })\n this.attachBlocks([shaped], blocksMap)\n }\n\n // PHASE 3: Merge translations if locale specified\n if (options?.locale) {\n const merged = await this.mergeTranslations([shaped], options.locale)\n return merged[0] as InferEntityDTO<AllFields>\n }\n\n return shaped as InferEntityDTO<AllFields>\n }\n\n /**\n * Find multiple entities\n * Auto-filters to published if entity has publishable() behavior\n * PHASE 3: Merges translations if locale is provided\n */\n async findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n this.logger?.info(\n { entity: this.entity.name, options, locale: options?.locale },\n 'Query: Finding entities',\n )\n\n // Build query\n let query = this.db.select().from(this.table).$dynamic()\n\n // Collect WHERE conditions\n const conditions: SQL[] = []\n\n // Auto-filter to published if entity has publishable behavior\n if (this.isPublishable) {\n conditions.push(this.buildPublishFilter(options?.locale))\n }\n\n if (options?.where) {\n conditions.push(options.where)\n }\n\n // Cursor-based pagination: add keyset WHERE condition (replaces OFFSET)\n if (options?.cursor) {\n // Whitelist: cursor field must be a real column on the entity\n const allFields = this.entity.allFields as Record<string, FieldConfig>\n if (!(options.cursor.field in allFields) && options.cursor.field !== 'id') {\n throw new Error(\n `Invalid cursor field: '${options.cursor.field}' is not a field on '${this.entity.name}'`,\n )\n }\n const cursorCondition = buildCursorCondition(this.table, options.cursor)\n if (cursorCondition) {\n conditions.push(cursorCondition)\n }\n }\n\n if (conditions.length > 0) {\n query = query.where(and(...conditions))\n }\n\n if (options?.limit) {\n query = query.limit(options.limit)\n }\n // Cursor takes precedence over offset — skip offset when cursor is active\n if (options?.offset && !options?.cursor) {\n query = query.offset(options.offset)\n }\n if (options?.orderBy) {\n const cols = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy]\n query = query.orderBy(...cols)\n }\n\n const rows = await query\n\n // Shape DTOs (filter out null results)\n const shaped = shapeDtos(this.entity, rows, { select: options?.select }).filter(\n (e): e is NonNullable<typeof e> => e !== null,\n ) as Record<string, unknown>[]\n\n // Attach blocks from layout table (with locale for block translations)\n if (this.getBlocksFields().length > 0 && shaped.length > 0) {\n const entityIds = shaped.map((e) => e.id as string)\n const blocksMap = await this.loadBlocks(entityIds, options?.locale, {\n defaultLocale: options?.defaultLocale,\n })\n this.attachBlocks(shaped, blocksMap)\n }\n\n // PHASE 3: Merge translations if locale specified\n if (options?.locale) {\n return (await this.mergeTranslations(shaped, options.locale)) as InferEntityDTO<AllFields>[]\n }\n\n return shaped as InferEntityDTO<AllFields>[]\n }\n\n /**\n * Count entities\n * Auto-filters to published if entity has publishable() behavior\n */\n async count(options?: CountOptions): Promise<number> {\n this.logger?.info({ entity: this.entity.name, options }, 'Query: Counting entities')\n\n // Build cache key: entityName + serialized WHERE (or empty for unfiltered)\n const cacheKey = this.buildCountCacheKey(options?.where)\n if (this.countCache) {\n const cached = this.countCache.get(cacheKey)\n if (cached !== undefined) {\n this.logger?.debug?.({ entity: this.entity.name, cached }, 'Count cache hit')\n return cached\n }\n }\n\n // Build count query\n let query = this.db.select({ count: sql<number>`count(*)` }).from(this.table).$dynamic()\n\n // Auto-filter to published if entity has publishable behavior\n if (this.isPublishable) {\n const publishedCondition = this.buildPublishFilter()\n\n if (options?.where) {\n query = query.where(and(publishedCondition, options.where))\n } else {\n query = query.where(publishedCondition)\n }\n } else if (options?.where) {\n query = query.where(options.where)\n }\n\n const [result] = await query\n const count = Number(result.count)\n\n // Cache the result\n if (this.countCache) {\n this.countCache.set(cacheKey, count)\n }\n\n return count\n }\n\n /**\n * Build publish filter SQL condition.\n * When a locale is provided and a locale_status table exists,\n * uses COALESCE to check locale-specific status first, falling back to base status.\n */\n private buildPublishFilter(locale?: string): SQL {\n if (locale) {\n const localeStatusTable = schemaRegistry.get(`${this.entity.name}_locale_status`)\n if (localeStatusTable) {\n return sql`COALESCE(\n (SELECT ${localeStatusTable.status} FROM ${localeStatusTable}\n WHERE ${localeStatusTable.entityId} = ${this.table.id}\n AND ${localeStatusTable.locale} = ${locale}),\n ${this.table.status}\n ) = 'published'`\n }\n }\n return eq(this.table.status, 'published')\n }\n\n /**\n * Merge translations into entities for the specified locale.\n * Reads translatable field values from real columns on the translation row.\n */\n private async mergeTranslations<T extends Record<string, unknown>>(\n entities: T[],\n locale: string,\n ): Promise<T[]> {\n if (!entities.length) return entities\n\n const translationTableName = `${this.entity.name}_translations`\n const translationTable = schemaRegistry.get(translationTableName)\n\n if (!translationTable) return entities\n\n const entityIds = entities.map((e) => e.id)\n\n const translations = await this.db\n .select()\n .from(translationTable)\n .where(\n and(inArray(translationTable.entityId, entityIds), eq(translationTable.locale, locale)),\n )\n\n // Get translatable field names\n const translatableFields = Object.entries(this.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, config]) => config.translatable)\n .map(([name]) => name)\n\n const translationMap = new Map<unknown, Record<string, unknown>>()\n for (const translation of translations) {\n const translatedValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n const value = (translation as Record<string, unknown>)[fieldName]\n if (value !== undefined && value !== null) {\n translatedValues[fieldName] = value\n }\n }\n translationMap.set(translation.entityId, translatedValues)\n }\n\n return entities.map((entity) => {\n const translatedFields = translationMap.get(entity.id)\n if (!translatedFields) return entity\n return { ...entity, ...translatedFields }\n })\n }\n\n // ---------------------------------------------------------------\n // Block loading (layout table)\n // ---------------------------------------------------------------\n\n /**\n * Get all blocks field names for this entity.\n */\n private getBlocksFields(): Array<{ name: string; config: FieldConfig }> {\n return Object.entries(this.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, config]) => config.type === 'blocks')\n .map(([name, config]) => ({ name, config }))\n }\n\n /**\n * Load blocks for one or more entities from the layout table.\n *\n * Handles both block translation modes:\n * - Shared layout (localized: false): loads locale=NULL rows, merges translations\n * (keeps base data as fallback for untranslated fields — visitor-facing)\n * - Per-locale layout (localized: true): loads rows matching the provided locale only\n */\n private async loadBlocks(\n entityIds: string[],\n locale?: string,\n options?: { defaultLocale?: string },\n ): Promise<Map<string, Record<string, unknown[]>>> {\n const blocksFields = this.getBlocksFields()\n if (blocksFields.length === 0 || entityIds.length === 0) {\n return new Map()\n }\n\n const layoutTable = schemaRegistry.get(`${this.entity.name}_layout`)\n if (!layoutTable) return new Map()\n\n // Determine block field modes\n const sharedFields = blocksFields.filter(\n ({ config }) => !('localized' in config && config.localized),\n )\n const localizedFields = blocksFields.filter(\n ({ config }) => 'localized' in config && config.localized,\n )\n\n const result = new Map<string, Record<string, unknown[]>>()\n\n // ---- Shared blocks: locale IS NULL, translations from layout_translations ----\n if (sharedFields.length > 0) {\n const rows = await this.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), isNull(layoutTable.locale)))\n .orderBy(layoutTable.sortOrder)\n\n // Load translations if locale provided (fallback: base data preserved)\n let blockTransMap: Map<string, Record<string, unknown>> | undefined\n if (locale && rows.length > 0) {\n const layoutTransTable = schemaRegistry.get(`${this.entity.name}_layout_translations`)\n if (layoutTransTable) {\n const layoutIds = rows.map((r) => r.id as string)\n const translations = await this.db\n .select()\n .from(layoutTransTable)\n .where(\n and(\n inArray(layoutTransTable.layoutId, layoutIds),\n eq(layoutTransTable.locale, locale),\n ),\n )\n\n blockTransMap = new Map()\n for (const t of translations) {\n blockTransMap.set(t.layoutId as string, (t.fields as Record<string, unknown>) ?? {})\n }\n }\n }\n\n for (const row of rows) {\n const eid = row.entityId as string\n const fname = row.fieldName as string\n\n if (!result.has(eid)) result.set(eid, {})\n const entityBlocks = result.get(eid)!\n if (!entityBlocks[fname]) entityBlocks[fname] = []\n\n const blockData = (row.data as Record<string, unknown>) ?? {}\n const translatedFields = blockTransMap?.get(row.id as string)\n\n // Merge translations on top of base data (fallback: base data for untranslated)\n entityBlocks[fname].push({\n _block: row.blockType,\n _id: row.id,\n ...blockData,\n ...(translatedFields ?? {}),\n })\n }\n }\n\n // ---- Localized blocks: filter by locale column ----\n // NULL rows (from initial create) only fall back for the default locale.\n // Non-default locales without locale-specific rows get an empty array.\n if (localizedFields.length > 0) {\n const isDefault = !locale || locale === options?.defaultLocale\n const localeCondition = locale\n ? isDefault\n ? or(eq(layoutTable.locale, locale), isNull(layoutTable.locale))!\n : eq(layoutTable.locale, locale)\n : isNull(layoutTable.locale)\n\n const rows = await this.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), localeCondition))\n .orderBy(layoutTable.sortOrder)\n\n // Group by entityId + fieldName, prefer locale-specific rows over NULL\n const grouped = new Map<string, { localeRows: typeof rows; nullRows: typeof rows }>()\n for (const row of rows) {\n const key = `${row.entityId}::${row.fieldName}`\n if (!grouped.has(key)) grouped.set(key, { localeRows: [], nullRows: [] })\n const group = grouped.get(key)!\n if (row.locale) {\n group.localeRows.push(row)\n } else {\n group.nullRows.push(row)\n }\n }\n\n for (const [, { localeRows, nullRows }] of grouped) {\n const effective = localeRows.length > 0 ? localeRows : nullRows\n\n for (const row of effective) {\n const eid = row.entityId as string\n const fname = row.fieldName as string\n\n if (!result.has(eid)) result.set(eid, {})\n const entityBlocks = result.get(eid)!\n if (!entityBlocks[fname]) entityBlocks[fname] = []\n\n const blockData = (row.data as Record<string, unknown>) ?? {}\n\n entityBlocks[fname].push({\n _block: row.blockType,\n _id: row.id,\n ...blockData,\n })\n }\n }\n }\n\n return result\n }\n\n /**\n * Attach loaded blocks to shaped DTOs.\n */\n private attachBlocks(\n entities: Record<string, unknown>[],\n blocksMap: Map<string, Record<string, unknown[]>>,\n ): void {\n const blocksFields = this.getBlocksFields()\n if (blocksFields.length === 0) return\n\n for (const entity of entities) {\n const eid = entity.id as string\n const entityBlocks = blocksMap.get(eid) ?? {}\n\n for (const { name } of blocksFields) {\n entity[name] = entityBlocks[name] ?? []\n }\n }\n }\n\n // ---------------------------------------------------------------\n // Count cache helpers\n // ---------------------------------------------------------------\n\n /**\n * Build a cache key for a count query.\n * Includes the publishable filter implicitly (all QueryClient counts include it).\n */\n private buildCountCacheKey(where?: SQL): string {\n const base = `query:${this.entity.name}`\n if (!where) return base\n return `${base}:${String(where)}`\n }\n\n // NO create/update/delete methods\n // These physically don't exist on the TypeScript type - security by design\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/cursor.ts","../../src/dto-shaper.ts","../../src/query/client.ts"],"sourcesContent":["/**\n * Cursor-based (keyset) pagination utilities.\n *\n * Cursor pagination avoids the performance cliff of OFFSET at scale (1M+ rows).\n * Instead of `OFFSET N`, it uses a WHERE condition:\n * `WHERE (sortField < lastValue) OR (sortField = lastValue AND id < lastId)`\n * which Postgres can serve from an index in constant time.\n *\n * The cursor is opaque to the client — base64-encoded JSON.\n *\n * Security:\n * - `field` must be whitelisted against the entity's actual fields\n * - `id` must be a valid UUID\n * - `value` is parameterized (never interpolated into SQL)\n * - Malformed cursors return null (caller returns 400)\n */\n\nimport { and, eq, getTableColumns, gt, lt, or, type SQL } from 'drizzle-orm'\nimport type { PgTable } from 'drizzle-orm/pg-core'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Cursor input for keyset pagination. */\nexport interface CursorInput {\n /** Sort field name (e.g. 'createdAt'). Must be a real column on the entity. */\n field: string\n /** Last seen value of the sort field. */\n value: string | number\n /** Sort direction — must match the ORDER BY direction. */\n direction: 'asc' | 'desc'\n /** Tie-breaker: last seen entity ID. Required for non-unique sort fields. */\n id?: string\n}\n\n/** Decoded cursor (internal, after validation). */\ninterface DecodedCursor {\n field: string\n value: string | number\n direction: 'asc' | 'desc'\n id?: string\n}\n\n// ---------------------------------------------------------------------------\n// UUID validation (same format used throughout the toolkit)\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n// ---------------------------------------------------------------------------\n// Encode / decode\n// ---------------------------------------------------------------------------\n\n/**\n * Encode a cursor for API transport (base64url).\n * Built from the last item in a result set.\n */\nexport function encodeCursor(\n item: Record<string, unknown>,\n sortField: string,\n direction: 'asc' | 'desc',\n): string {\n const payload: CursorInput = {\n field: sortField,\n value: item[sortField] as string | number,\n direction,\n id: item.id as string | undefined,\n }\n return btoa(JSON.stringify(payload))\n}\n\n/**\n * Decode and validate a cursor string from query params.\n * Returns null if the cursor is malformed, tampered, or invalid.\n *\n * Security: the `field` value is NOT validated here — the caller must\n * whitelist it against the entity's actual columns.\n */\nexport function decodeCursor(encoded: string): DecodedCursor | null {\n try {\n const json = atob(encoded)\n const parsed: unknown = JSON.parse(json)\n\n if (typeof parsed !== 'object' || parsed === null) return null\n const obj = parsed as Record<string, unknown>\n\n // Validate field\n if (typeof obj.field !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(obj.field)) {\n return null\n }\n\n // Validate value (string or number)\n if (typeof obj.value !== 'string' && typeof obj.value !== 'number') {\n return null\n }\n\n // Validate direction\n if (obj.direction !== 'asc' && obj.direction !== 'desc') {\n return null\n }\n\n // Validate id (optional, must be UUID if present)\n if (obj.id !== undefined) {\n if (typeof obj.id !== 'string' || !UUID_RE.test(obj.id)) {\n return null\n }\n }\n\n return {\n field: obj.field,\n value: obj.value,\n direction: obj.direction,\n id: obj.id as string | undefined,\n }\n } catch {\n return null\n }\n}\n\n// ---------------------------------------------------------------------------\n// SQL condition builder\n// ---------------------------------------------------------------------------\n\n/**\n * Build the keyset WHERE condition from a decoded cursor.\n *\n * For DESC: `WHERE (field < value) OR (field = value AND id < cursorId)`\n * For ASC: `WHERE (field > value) OR (field = value AND id > cursorId)`\n *\n * The caller must verify that `cursor.field` exists on the table before calling.\n *\n * @param table - Drizzle table with columns\n * @param cursor - Decoded and validated cursor\n * @returns SQL condition, or null if the field doesn't exist on the table\n */\nexport function buildCursorCondition(\n table: PgTable,\n cursor: DecodedCursor,\n): SQL | null {\n const cols = getTableColumns(table)\n const column = cols[cursor.field]\n if (!column) return null\n\n const isDesc = cursor.direction === 'desc'\n const compare = isDesc ? lt : gt\n\n // Primary condition: sort field passes the cursor value\n const fieldCondition = compare(column, cursor.value)\n\n // Without tie-breaker ID, use simple comparison\n if (!cursor.id) {\n return fieldCondition\n }\n\n // With tie-breaker: (field < value) OR (field = value AND id < cursorId)\n const idColumn = cols.id\n if (!idColumn) return fieldCondition\n\n const idCondition = compare(idColumn, cursor.id)\n return or(fieldCondition, and(eq(column, cursor.value), idCondition))!\n}\n","/**\n * DTO Shaper\n * Transforms raw DB rows into clean DTOs.\n * All fields are real columns — just read from row directly.\n */\n\nimport type { Entity } from './define-entity.js'\nimport type { FieldConfig } from './fields/base.js'\nimport type { InferEntityDTO } from './types/infer.js'\n\nexport interface ShapeDtoOptions {\n select?: string[] // Explicit field selection\n includeInternal?: boolean // Include _version, _scopeId\n}\n\n/**\n * Shape a raw DB row into a typed DTO.\n * Every field is a real column — read directly from row.\n */\nexport function shapeDto<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n row: Record<string, unknown>,\n options?: ShapeDtoOptions,\n): InferEntityDTO<AllFields> | null {\n if (!row) return null\n\n const result: Record<string, unknown> = {}\n const fieldsToInclude = options?.select || Object.keys(entity.allFields)\n\n for (const fieldName of fieldsToInclude) {\n if (!options?.includeInternal && fieldName.startsWith('_')) continue\n\n const fieldConfig = (entity.allFields as Record<string, FieldConfig>)[fieldName]\n if (!fieldConfig) continue\n\n // Blocks are loaded separately from layout tables\n if (fieldConfig.type === 'blocks') continue\n\n result[fieldName] = row[fieldName]\n }\n\n return result as InferEntityDTO<AllFields>\n}\n\n/**\n * Shape multiple rows\n */\nexport function shapeDtos<AllFields extends Record<string, FieldConfig>>(\n entity: Entity<AllFields>,\n rows: Record<string, unknown>[],\n options?: ShapeDtoOptions,\n): (InferEntityDTO<AllFields> | null)[] {\n return rows.map((row) => shapeDto(entity, row, options))\n}\n","/**\n * QueryClient - Read-only client for frontend use\n * SAFE for client bundles - NO 'server-only' import\n */\n\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { and, eq, inArray, isNull, or, type SQL, sql } from 'drizzle-orm'\nimport type { PgTableWithColumns } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { CountCacheLike } from '../count-cache.js'\nimport type { CursorInput } from '../cursor.js'\nimport { buildCursorCondition } from '../cursor.js'\nimport type { Entity } from '../define-entity.js'\nimport { shapeDto, shapeDtos } from '../dto-shaper.js'\nimport type { FieldConfig } from '../fields/base.js'\nimport type { InferEntityDTO } from '../types/infer.js'\nimport type { Logger } from '../types/logger.js'\n\nexport interface QueryClientConfig<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n entity: Entity<AllFields>\n db: PostgresJsDatabase // Read-only connection (enforced at PostgreSQL level)\n logger?: Logger\n /** Optional count cache for COUNT(*) query optimization. */\n countCache?: CountCacheLike\n}\n\nexport interface FindByIdOptions {\n select?: string[]\n locale?: string\n /** Default content locale. For localized blocks, NULL rows (from initial create)\n * are only returned as fallback when locale matches defaultLocale. */\n defaultLocale?: string\n}\n\nexport interface FindManyOptions {\n where?: SQL | undefined\n limit?: number\n offset?: number\n orderBy?: SQL | SQL[]\n select?: string[]\n locale?: string\n defaultLocale?: string\n /**\n * Cursor-based (keyset) pagination. When provided, replaces OFFSET with a\n * WHERE condition for O(1) page access at any depth. The `offset` option\n * is ignored when `cursor` is set.\n *\n * The cursor `field` must be a real column on the entity table.\n */\n cursor?: CursorInput\n}\n\nexport interface CountOptions {\n where?: SQL | undefined\n}\n\n/**\n * QueryClient - Read-only entity access for frontends\n *\n * Security:\n * - NO 'server-only' import (safe for client bundles)\n * - Uses read-only DB connection (PostgreSQL enforces with default_transaction_read_only=on)\n * - NO mutation methods on TypeScript type (create/update/delete don't exist)\n * - Auto-filters to published entities if entity has publishable() behavior\n *\n * Phase 1: Core read operations + publishable filtering\n * Phase 2 (TODO): Translation merging, reference population\n *\n * @typeParam AllFields - The entity's complete field map. Inferred automatically\n * when constructing via `createQueryClient(entity)`.\n */\nexport class QueryClient<\n AllFields extends Record<string, FieldConfig> = Record<string, FieldConfig>,\n> {\n private entity: Entity<AllFields>\n private db: PostgresJsDatabase\n private logger?: Logger\n // biome-ignore lint/suspicious/noExplicitAny: dynamic table columns require PgTableWithColumns<any> for property access\n private table: PgTableWithColumns<any>\n private isPublishable: boolean\n private countCache?: CountCacheLike\n\n constructor(config: QueryClientConfig<AllFields>) {\n this.entity = config.entity\n this.db = config.db // Assumes read-only connection passed from outside\n this.logger = config.logger\n this.countCache = config.countCache\n\n // Detect if entity has publishable behavior\n this.isPublishable = config.entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n\n // Get table from schema registry\n const table = schemaRegistry.get(config.entity.name)\n if (!table) {\n throw new Error(\n `Schema for entity '${config.entity.name}' not found in registry. ` +\n 'Ensure schemas are generated and registered before creating QueryClient.',\n )\n }\n this.table = table\n }\n\n /**\n * Find entity by ID\n * Auto-filters to published if entity has publishable() behavior\n * PHASE 3: Merges translations if locale is provided\n */\n async findById(id: string, options?: FindByIdOptions): Promise<InferEntityDTO<AllFields> | null> {\n this.logger?.info(\n { entity: this.entity.name, id, locale: options?.locale },\n 'Query: Finding entity by ID',\n )\n\n // Query database\n const whereConditions = [eq(this.table.id, id)]\n\n // Auto-filter to published if entity has publishable behavior\n if (this.isPublishable) {\n whereConditions.push(this.buildPublishFilter(options?.locale))\n }\n\n const [row] = await this.db\n .select()\n .from(this.table)\n .where(and(...whereConditions))\n\n if (!row) return null\n\n // Shape DTO (row is non-null here, so shaped result will be non-null)\n const shaped = shapeDto(this.entity, row, { select: options?.select }) as Record<\n string,\n unknown\n >\n if (!shaped) return null\n\n // Attach blocks from layout table (with locale for block translations)\n if (this.getBlocksFields().length > 0) {\n const blocksMap = await this.loadBlocks([shaped.id as string], options?.locale, {\n defaultLocale: options?.defaultLocale,\n })\n this.attachBlocks([shaped], blocksMap)\n }\n\n // PHASE 3: Merge translations if locale specified\n if (options?.locale) {\n const merged = await this.mergeTranslations([shaped], options.locale)\n return merged[0] as InferEntityDTO<AllFields>\n }\n\n return shaped as InferEntityDTO<AllFields>\n }\n\n /**\n * Find multiple entities\n * Auto-filters to published if entity has publishable() behavior\n * PHASE 3: Merges translations if locale is provided\n */\n async findMany(options?: FindManyOptions): Promise<InferEntityDTO<AllFields>[]> {\n this.logger?.info(\n { entity: this.entity.name, options, locale: options?.locale },\n 'Query: Finding entities',\n )\n\n // Build query\n let query = this.db.select().from(this.table).$dynamic()\n\n // Collect WHERE conditions\n const conditions: SQL[] = []\n\n // Auto-filter to published if entity has publishable behavior\n if (this.isPublishable) {\n conditions.push(this.buildPublishFilter(options?.locale))\n }\n\n if (options?.where) {\n conditions.push(options.where)\n }\n\n // Cursor-based pagination: add keyset WHERE condition (replaces OFFSET)\n if (options?.cursor) {\n // Whitelist: cursor field must be a real column on the entity\n const allFields = this.entity.allFields as Record<string, FieldConfig>\n if (!(options.cursor.field in allFields) && options.cursor.field !== 'id') {\n throw new Error(\n `Invalid cursor field: '${options.cursor.field}' is not a field on '${this.entity.name}'`,\n )\n }\n const cursorCondition = buildCursorCondition(this.table, options.cursor)\n if (cursorCondition) {\n conditions.push(cursorCondition)\n }\n }\n\n if (conditions.length > 0) {\n query = query.where(and(...conditions))\n }\n\n if (options?.limit) {\n query = query.limit(options.limit)\n }\n // Cursor takes precedence over offset — skip offset when cursor is active\n if (options?.offset && !options?.cursor) {\n query = query.offset(options.offset)\n }\n if (options?.orderBy) {\n const cols = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy]\n query = query.orderBy(...cols)\n }\n\n const rows = await query\n\n // Shape DTOs (filter out null results)\n const shaped = shapeDtos(this.entity, rows, { select: options?.select }).filter(\n (e): e is NonNullable<typeof e> => e !== null,\n ) as Record<string, unknown>[]\n\n // Attach blocks from layout table (with locale for block translations)\n if (this.getBlocksFields().length > 0 && shaped.length > 0) {\n const entityIds = shaped.map((e) => e.id as string)\n const blocksMap = await this.loadBlocks(entityIds, options?.locale, {\n defaultLocale: options?.defaultLocale,\n })\n this.attachBlocks(shaped, blocksMap)\n }\n\n // PHASE 3: Merge translations if locale specified\n if (options?.locale) {\n return (await this.mergeTranslations(shaped, options.locale)) as InferEntityDTO<AllFields>[]\n }\n\n return shaped as InferEntityDTO<AllFields>[]\n }\n\n /**\n * Count entities\n * Auto-filters to published if entity has publishable() behavior\n */\n async count(options?: CountOptions): Promise<number> {\n this.logger?.info({ entity: this.entity.name, options }, 'Query: Counting entities')\n\n // Build cache key: entityName + serialized WHERE (or empty for unfiltered)\n const cacheKey = this.buildCountCacheKey(options?.where)\n if (this.countCache) {\n const cached = this.countCache.get(cacheKey)\n if (cached !== undefined) {\n this.logger?.debug?.({ entity: this.entity.name, cached }, 'Count cache hit')\n return cached\n }\n }\n\n // Build count query\n let query = this.db.select({ count: sql<number>`count(*)` }).from(this.table).$dynamic()\n\n // Auto-filter to published if entity has publishable behavior\n if (this.isPublishable) {\n const publishedCondition = this.buildPublishFilter()\n\n if (options?.where) {\n query = query.where(and(publishedCondition, options.where))\n } else {\n query = query.where(publishedCondition)\n }\n } else if (options?.where) {\n query = query.where(options.where)\n }\n\n const [result] = await query\n const count = Number(result.count)\n\n // Cache the result\n if (this.countCache) {\n this.countCache.set(cacheKey, count)\n }\n\n return count\n }\n\n /**\n * Build publish filter SQL condition.\n * When a locale is provided and a locale_status table exists,\n * uses COALESCE to check locale-specific status first, falling back to base status.\n */\n private buildPublishFilter(locale?: string): SQL {\n if (locale) {\n const localeStatusTable = schemaRegistry.get(`${this.entity.name}_locale_status`)\n if (localeStatusTable) {\n return sql`COALESCE(\n (SELECT ${localeStatusTable.status} FROM ${localeStatusTable}\n WHERE ${localeStatusTable.entityId} = ${this.table.id}\n AND ${localeStatusTable.locale} = ${locale}),\n ${this.table.status}\n ) = 'published'`\n }\n }\n return eq(this.table.status, 'published')\n }\n\n /**\n * Merge translations into entities for the specified locale.\n * Reads translatable field values from real columns on the translation row.\n */\n private async mergeTranslations<T extends Record<string, unknown>>(\n entities: T[],\n locale: string,\n ): Promise<T[]> {\n if (!entities.length) return entities\n\n const translationTableName = `${this.entity.name}_translations`\n const translationTable = schemaRegistry.get(translationTableName)\n\n if (!translationTable) return entities\n\n const entityIds = entities.map((e) => e.id)\n\n const translations = await this.db\n .select()\n .from(translationTable)\n .where(\n and(inArray(translationTable.entityId, entityIds), eq(translationTable.locale, locale)),\n )\n\n // Get translatable field names\n const translatableFields = Object.entries(this.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, config]) => config.translatable)\n .map(([name]) => name)\n\n const translationMap = new Map<unknown, Record<string, unknown>>()\n for (const translation of translations) {\n const translatedValues: Record<string, unknown> = {}\n for (const fieldName of translatableFields) {\n const value = (translation as Record<string, unknown>)[fieldName]\n if (value !== undefined && value !== null) {\n translatedValues[fieldName] = value\n }\n }\n translationMap.set(translation.entityId, translatedValues)\n }\n\n return entities.map((entity) => {\n const translatedFields = translationMap.get(entity.id)\n if (!translatedFields) return entity\n return { ...entity, ...translatedFields }\n })\n }\n\n // ---------------------------------------------------------------\n // Block loading (layout table)\n // ---------------------------------------------------------------\n\n /**\n * Get all blocks field names for this entity.\n */\n private getBlocksFields(): Array<{ name: string; config: FieldConfig }> {\n return Object.entries(this.entity.allFields as Record<string, FieldConfig>)\n .filter(([_, config]) => config.type === 'blocks')\n .map(([name, config]) => ({ name, config }))\n }\n\n /**\n * Load blocks for one or more entities from the layout table.\n *\n * Handles both block translation modes:\n * - Shared layout (localized: false): loads locale=NULL rows, merges translations\n * (keeps base data as fallback for untranslated fields — visitor-facing)\n * - Per-locale layout (localized: true): loads rows matching the provided locale only\n */\n private async loadBlocks(\n entityIds: string[],\n locale?: string,\n options?: { defaultLocale?: string },\n ): Promise<Map<string, Record<string, unknown[]>>> {\n const blocksFields = this.getBlocksFields()\n if (blocksFields.length === 0 || entityIds.length === 0) {\n return new Map()\n }\n\n const layoutTable = schemaRegistry.get(`${this.entity.name}_layout`)\n if (!layoutTable) return new Map()\n\n // Determine block field modes\n const sharedFields = blocksFields.filter(\n ({ config }) => !('localized' in config && config.localized),\n )\n const localizedFields = blocksFields.filter(\n ({ config }) => 'localized' in config && config.localized,\n )\n\n const result = new Map<string, Record<string, unknown[]>>()\n\n // ---- Shared blocks: locale IS NULL, translations from layout_translations ----\n if (sharedFields.length > 0) {\n const rows = await this.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), isNull(layoutTable.locale)))\n .orderBy(layoutTable.sortOrder)\n\n // Load translations if locale provided (fallback: base data preserved)\n let blockTransMap: Map<string, Record<string, unknown>> | undefined\n if (locale && rows.length > 0) {\n const layoutTransTable = schemaRegistry.get(`${this.entity.name}_layout_translations`)\n if (layoutTransTable) {\n const layoutIds = rows.map((r) => r.id as string)\n const translations = await this.db\n .select()\n .from(layoutTransTable)\n .where(\n and(\n inArray(layoutTransTable.layoutId, layoutIds),\n eq(layoutTransTable.locale, locale),\n ),\n )\n\n blockTransMap = new Map()\n for (const t of translations) {\n blockTransMap.set(t.layoutId as string, (t.fields as Record<string, unknown>) ?? {})\n }\n }\n }\n\n for (const row of rows) {\n const eid = row.entityId as string\n const fname = row.fieldName as string\n\n if (!result.has(eid)) result.set(eid, {})\n const entityBlocks = result.get(eid)!\n if (!entityBlocks[fname]) entityBlocks[fname] = []\n\n const blockData = (row.data as Record<string, unknown>) ?? {}\n const translatedFields = blockTransMap?.get(row.id as string)\n\n // Merge translations on top of base data (fallback: base data for untranslated)\n entityBlocks[fname].push({\n _block: row.blockType,\n _id: row.id,\n ...blockData,\n ...(translatedFields ?? {}),\n })\n }\n }\n\n // ---- Localized blocks: filter by locale column ----\n // NULL rows (from initial create) only fall back for the default locale.\n // Non-default locales without locale-specific rows get an empty array.\n if (localizedFields.length > 0) {\n const isDefault = !locale || locale === options?.defaultLocale\n const localeCondition = locale\n ? isDefault\n ? or(eq(layoutTable.locale, locale), isNull(layoutTable.locale))!\n : eq(layoutTable.locale, locale)\n : isNull(layoutTable.locale)\n\n const rows = await this.db\n .select()\n .from(layoutTable)\n .where(and(inArray(layoutTable.entityId, entityIds), localeCondition))\n .orderBy(layoutTable.sortOrder)\n\n // Group by entityId + fieldName, prefer locale-specific rows over NULL\n const grouped = new Map<string, { localeRows: typeof rows; nullRows: typeof rows }>()\n for (const row of rows) {\n const key = `${row.entityId}::${row.fieldName}`\n if (!grouped.has(key)) grouped.set(key, { localeRows: [], nullRows: [] })\n const group = grouped.get(key)!\n if (row.locale) {\n group.localeRows.push(row)\n } else {\n group.nullRows.push(row)\n }\n }\n\n for (const [, { localeRows, nullRows }] of grouped) {\n const effective = localeRows.length > 0 ? localeRows : nullRows\n\n for (const row of effective) {\n const eid = row.entityId as string\n const fname = row.fieldName as string\n\n if (!result.has(eid)) result.set(eid, {})\n const entityBlocks = result.get(eid)!\n if (!entityBlocks[fname]) entityBlocks[fname] = []\n\n const blockData = (row.data as Record<string, unknown>) ?? {}\n\n entityBlocks[fname].push({\n _block: row.blockType,\n _id: row.id,\n ...blockData,\n })\n }\n }\n }\n\n return result\n }\n\n /**\n * Attach loaded blocks to shaped DTOs.\n */\n private attachBlocks(\n entities: Record<string, unknown>[],\n blocksMap: Map<string, Record<string, unknown[]>>,\n ): void {\n const blocksFields = this.getBlocksFields()\n if (blocksFields.length === 0) return\n\n for (const entity of entities) {\n const eid = entity.id as string\n const entityBlocks = blocksMap.get(eid) ?? {}\n\n for (const { name } of blocksFields) {\n entity[name] = entityBlocks[name] ?? []\n }\n }\n }\n\n // ---------------------------------------------------------------\n // Count cache helpers\n // ---------------------------------------------------------------\n\n /**\n * Build a cache key for a count query.\n * Includes the publishable filter implicitly (all QueryClient counts include it).\n */\n private buildCountCacheKey(where?: SQL): string {\n const base = `query:${this.entity.name}`\n if (!where) return base\n return `${base}:${String(where)}`\n }\n\n // NO create/update/delete methods\n // These physically don't exist on the TypeScript type - security by design\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/entity",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,21 +24,21 @@
24
24
  "files": [
25
25
  "dist"
26
26
  ],
27
+ "scripts": {
28
+ "build": "tsdown",
29
+ "dev": "tsdown --watch",
30
+ "test": "vitest",
31
+ "test:integration": "vitest run --config vitest.integration.config.ts"
32
+ },
27
33
  "dependencies": {
34
+ "@murumets-ee/db": "workspace:*",
28
35
  "drizzle-orm": "^0.45.1",
29
36
  "zod": "^3.24.1",
30
- "server-only": "^0.0.1",
31
- "@murumets-ee/db": "0.2.0"
37
+ "server-only": "^0.0.1"
32
38
  },
33
39
  "devDependencies": {
34
40
  "tsdown": "^0.21.7",
35
41
  "typescript": "^5.7.2",
36
42
  "vitest": "^2.1.8"
37
- },
38
- "scripts": {
39
- "build": "tsdown",
40
- "dev": "tsdown --watch",
41
- "test": "vitest",
42
- "test:integration": "vitest run --config vitest.integration.config.ts"
43
43
  }
44
- }
44
+ }
package/LICENSE DELETED
@@ -1,94 +0,0 @@
1
- Elastic License 2.0 (ELv2)
2
-
3
- URL: https://www.elastic.co/licensing/elastic-license
4
-
5
- ## Acceptance
6
-
7
- By using the software, you agree to all of the terms and conditions below.
8
-
9
- ## Copyright License
10
-
11
- The licensor grants you a non-exclusive, royalty-free, worldwide,
12
- non-sublicensable, non-transferable license to use, copy, distribute, make
13
- available, and prepare derivative works of the software, in each case subject
14
- to the limitations and conditions below.
15
-
16
- ## Limitations
17
-
18
- You may not provide the software to third parties as a hosted or managed
19
- service, where the service provides users with access to any substantial set
20
- of the features or functionality of the software.
21
-
22
- You may not move, change, disable, or circumvent the license key functionality
23
- in the software, and you may not remove or obscure any functionality in the
24
- software that is protected by the license key.
25
-
26
- You may not alter, remove, or obscure any licensing, copyright, or other
27
- notices of the licensor in the software. Any use of the licensor's trademarks
28
- is subject to applicable law.
29
-
30
- ## Patents
31
-
32
- The licensor grants you a license, under any patent claims the licensor can
33
- license, or becomes able to license, to make, have made, use, sell, offer for
34
- sale, import and have imported the software, in each case subject to the
35
- limitations and conditions in this license. This license does not cover any
36
- patent claims that you cause to be infringed by modifications or additions to
37
- the software. If you or your company make any written claim that the software
38
- infringes or contributes to infringement of any patent, your patent license
39
- for the software granted under these terms ends immediately. If your company
40
- makes such a claim, your patent license ends immediately for work on behalf
41
- of your company.
42
-
43
- ## Notices
44
-
45
- You must ensure that anyone who gets a copy of any part of the software from
46
- you also gets a copy of these terms.
47
-
48
- If you modify the software, you must include in any modified copies of the
49
- software prominent notices stating that you have modified the software.
50
-
51
- ## No Other Rights
52
-
53
- These terms do not imply any licenses other than those expressly granted in
54
- these terms.
55
-
56
- ## Termination
57
-
58
- If you use the software in violation of these terms, such use is not licensed,
59
- and your licenses will automatically terminate. If the licensor provides you
60
- with a notice of your violation, and you cease all violation of this license
61
- no later than 30 days after you receive that notice, your licenses will be
62
- reinstated retroactively. However, if you violate these terms after such
63
- reinstatement, any additional violation of these terms will cause your
64
- licenses to terminate automatically and permanently.
65
-
66
- ## No Liability
67
-
68
- As far as the law allows, the software comes as is, without any warranty or
69
- condition, and the licensor will not be liable to you for any damages arising
70
- out of these terms or the use or nature of the software, under any kind of
71
- legal claim.
72
-
73
- ## Definitions
74
-
75
- The **licensor** is the entity offering these terms, and the **software** is
76
- the software the licensor makes available under these terms, including any
77
- portion of it.
78
-
79
- **you** refers to the individual or entity agreeing to these terms.
80
-
81
- **your company** is any legal entity, sole proprietorship, or other kind of
82
- organization that you work for, plus all organizations that have control over,
83
- are under the control of, or are under common control with that organization.
84
- **control** means ownership of substantially all the assets of an entity, or
85
- the power to direct the management and policies of an entity (for example, by
86
- voting right, contract, or otherwise). Control can be direct or indirect.
87
-
88
- **your licenses** are all the licenses granted to you for the software under
89
- these terms.
90
-
91
- **use** means anything you do with the software requiring one of your
92
- licenses.
93
-
94
- **trademark** means trademarks, service marks, and similar rights.