{"version":3,"file":"base-notifications-repository.mjs","names":[],"sources":["../../../../../../../notifications/src/in-app/base-notifications-repository.ts"],"sourcesContent":["/**\n * Shipped behavior repository for the in-app store.\n *\n * Concrete (NOT abstract) so `inApp.configure({ model })` can\n * `new BaseNotificationsRepository(model)` without subclassing. The dev only\n * extends this when they need extra query methods.\n *\n * ## The single source of truth: the model's `columnMap`\n *\n * The model declares its physical columns ONCE via `static columnMap`. The\n * constructor resolves that map and derives BOTH sides from it:\n *\n *  - the READ path — `filterBy` maps logical keys (`recipientId`, `unread`,\n *    `type`, …) onto the model's columns; `RepositoryManager` applies it.\n *  - the WRITE / UPDATE / DELETE path — `createFor` / `markRead` / … translate\n *    logical keys through the same `filterBy` via `toRow`.\n *\n * Because both come from one `columnMap`, reads and writes can never target\n * different columns. `unread` is mode-agnostic: it filters `is_read = false`\n * when the model has that flag, otherwise `read_at IS NULL`.\n *\n * @example  default (SQL snake_case — no subclass needed)\n *   inApp.configure({ model: Notification })\n *\n * @example  custom columns — declare them on the model, not here\n *   class Notification extends DatabaseNotification {\n *     public static columnMap = { recipient: \"audience_id\", isRead: \"seen\" };\n *   }\n */\nimport { type FilterRules, RepositoryManager } from \"@warlock.js/core\";\nimport type { NotificationContract } from \"../contracts\";\nimport type { DatabasePayload, Id } from \"../types\";\nimport {\n  type NotificationColumnMapHost,\n  type ResolvedNotificationColumnMap,\n  resolveColumnMap,\n} from \"./column-map\";\nimport type { DatabaseNotification } from \"./database-notification\";\n\n/**\n * Filter shape — logical keys the repo understands. `filterBy` maps each to a\n * physical column (or a predicate) derived from the model's `columnMap`.\n */\nexport type NotificationsFilter = {\n  id?: Id;\n  type?: string;\n  /** Mode-agnostic unread filter — resolves to the model's read-state column. */\n  unread?: boolean;\n  recipientId?: Id;\n  idempotencyKey?: string;\n};\n\n/** Class form of a `DatabaseNotification` subclass — what `configure()` accepts. */\nexport type NotificationModelClass = (new (...args: any[]) => DatabaseNotification) &\n  NotificationColumnMapHost;\n\nexport class BaseNotificationsRepository<\n  TModel extends NotificationContract = NotificationContract,\n> extends RepositoryManager<TModel, NotificationsFilter> {\n  /** Resolved physical columns for the bound model — the single source. */\n  private readonly columns: ResolvedNotificationColumnMap;\n\n  public filterBy: FilterRules;\n\n  public constructor(model?: NotificationModelClass) {\n    super();\n\n    if (model) {\n      this.source = model;\n    }\n\n    this.columns = resolveColumnMap(model?.columnMap);\n    this.filterBy = this.buildFilterBy();\n  }\n\n  /** The tenant column for this model, or `undefined` for single-tenant. */\n  public get tenantColumn(): string | undefined {\n    return this.columns.tenant;\n  }\n\n  /**\n   * Build `filterBy` from the resolved column map. `unread` is the\n   * mode-agnostic read filter; `isRead` / `readAt` / `tenant` are exposed for\n   * direct filtering only when the model declares those columns.\n   */\n  private buildFilterBy(): FilterRules {\n    const { recipient, tenant, readAt, isRead } = this.columns;\n\n    const rules: FilterRules = {\n      id: \"=\",\n      type: \"=\",\n      recipientId: [\"=\", recipient],\n      idempotencyKey: [\"=\", \"idempotency_key\"],\n      unread: (value, query) => {\n        if (value !== true && value !== \"true\") {\n          return;\n        }\n\n        if (isRead) {\n          query.where(isRead, false);\n        } else if (readAt) {\n          query.whereNull(readAt);\n        }\n      },\n    };\n\n    if (isRead) {\n      rules.isRead = [\"=\", isRead];\n    }\n\n    if (readAt) {\n      rules.readAt = [\"=\", readAt];\n    }\n\n    if (tenant) {\n      rules.tenant = [\"=\", tenant];\n    }\n\n    return rules;\n  }\n\n  /**\n   * Translate an object keyed by LOGICAL names into one keyed by PHYSICAL\n   * columns, using `filterBy` as the lookup. Keys with no `[op, column]` tuple\n   * (e.g. `title`, `body`, `payload`, `type`, `id`) pass through unchanged —\n   * they're already physical. Used for every write / update / delete payload +\n   * filter (the paths `RepositoryManager` does NOT map).\n   */\n  protected toRow(obj: Record<string, unknown>): Record<string, unknown> {\n    const rules = this.filterBy as Record<string, unknown>;\n    const row: Record<string, unknown> = {};\n\n    for (const [logical, value] of Object.entries(obj)) {\n      const rule = rules[logical];\n      const column = Array.isArray(rule) ? (rule[1] as string) : logical;\n      row[column] = value;\n    }\n\n    return row;\n  }\n\n  /**\n   * Runtime backstop for the compile-time `DatabasePayload` `Omit`: untyped\n   * callers (`notify.channel(name).send`, payloads assembled from request\n   * JSON) can smuggle server-owned keys past the type system, and spreading\n   * them into the row would let a payload redirect the notification into\n   * another recipient's inbox (recipient spoofing), cross tenants, pre-set\n   * read-state, or clobber the primary key. Drops BOTH the logical names and\n   * the model's resolved physical columns — `toRow` passes unmapped keys\n   * through verbatim, so a payload carrying `user_id` would otherwise reach\n   * the recipient column directly.\n   */\n  private stripServerOwnedKeys(input: DatabasePayload): Record<string, unknown> {\n    const { recipient, tenant, readAt, isRead } = this.columns;\n    const serverOwned = new Set<string | undefined>([\n      \"id\",\n      \"recipientId\",\n      \"tenant\",\n      \"readAt\",\n      \"isRead\",\n      recipient,\n      tenant,\n      readAt,\n      isRead,\n    ]);\n\n    const safe: Record<string, unknown> = {};\n\n    for (const [key, value] of Object.entries(input)) {\n      if (!serverOwned.has(key)) {\n        safe[key] = value;\n      }\n    }\n\n    return safe;\n  }\n\n  /**\n   * Create one row for the recipient. New rows start unread; the tenant column\n   * (when the model declares one) is written from `tenantId`, which the\n   * database channel reads off the recipient. `recipientId` and the other\n   * server-owned keys always come from the trusted arguments — matching keys\n   * in `input` are stripped, never merged.\n   *\n   * Idempotency: when `input.idempotencyKey` is set, find-or-create — return\n   * the existing row instead of inserting a duplicate. A unique index on the\n   * key is the backstop for the rare insert race (we re-fetch on conflict).\n   */\n  public async createFor(\n    recipientId: Id,\n    input: DatabasePayload,\n    tenantId?: Id,\n  ): Promise<TModel> {\n    const { idempotencyKey } = input;\n\n    if (idempotencyKey) {\n      const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);\n      if (existing) {\n        return existing;\n      }\n    }\n\n    const row: Record<string, unknown> = { ...this.stripServerOwnedKeys(input), recipientId };\n\n    if (this.columns.isRead) {\n      row.isRead = false;\n    }\n\n    if (this.columns.tenant && tenantId !== undefined) {\n      row.tenant = tenantId;\n    }\n\n    try {\n      return await this.create(this.toRow(row));\n    } catch (error) {\n      // Lost the insert race to a concurrent send with the same key → re-fetch.\n      if (idempotencyKey) {\n        const existing = await this.firstByIdempotencyKey(recipientId, idempotencyKey);\n        if (existing) {\n          return existing;\n        }\n      }\n\n      throw error;\n    }\n  }\n\n  /**\n   * Bulk-create one row per recipient sharing the same input + tenant. Reserved\n   * for the Phase-2 `Channel.sendMany` fan-out hook; not on the per-recipient\n   * send path yet.\n   */\n  public createManyFor(\n    recipientIds: Id[],\n    input: DatabasePayload,\n    tenantId?: Id,\n  ): Promise<TModel[]> {\n    return Promise.all(\n      recipientIds.map((recipientId) => this.createFor(recipientId, input, tenantId)),\n    );\n  }\n\n  /**\n   * Mark rows read — recipient-scoped. `id` omitted → all unread rows for the\n   * recipient; `id` given → that one row, still scoped to the recipient. Scoped\n   * to unread so a re-mark never overwrites an earlier `read_at` timestamp.\n   * Sets whichever read-state column(s) the model declares.\n   */\n  public markRead(recipientId: Id, id?: Id): Promise<number> {\n    const { isRead, readAt } = this.columns;\n    const unreadScope = isRead ? { isRead: false } : { readAt: null };\n\n    const data: Record<string, unknown> = {};\n\n    if (isRead) {\n      data.isRead = true;\n    }\n\n    if (readAt) {\n      data.readAt = new Date();\n    }\n\n    return this.updateMany(\n      this.toRow({ recipientId, ...unreadScope, ...(id !== undefined ? { id } : {}) }),\n      this.toRow(data),\n    );\n  }\n\n  /**\n   * Inverse of `markRead`, same recipient-scoping. No unread scope needed —\n   * clearing the read-state of an already-unread row is a no-op.\n   */\n  public markUnread(recipientId: Id, id?: Id): Promise<number> {\n    const { isRead, readAt } = this.columns;\n    const data: Record<string, unknown> = {};\n\n    if (isRead) {\n      data.isRead = false;\n    }\n\n    if (readAt) {\n      data.readAt = null;\n    }\n\n    return this.updateMany(\n      this.toRow({ recipientId, ...(id !== undefined ? { id } : {}) }),\n      this.toRow(data),\n    );\n  }\n\n  /** Find one row for a recipient (read path — `filterBy` maps the keys). */\n  public findFor(recipientId: Id, id: Id): Promise<TModel | null> {\n    return this.first({ recipientId, id } as NotificationsFilter);\n  }\n\n  /**\n   * Delete rows — recipient-scoped. `id` omitted → clear all for the\n   * recipient; `id` given → that one row.\n   */\n  public deleteFor(recipientId: Id, id?: Id): Promise<number> {\n    return this.deleteMany(this.toRow({ recipientId, ...(id !== undefined ? { id } : {}) }));\n  }\n\n  /** Read path — `filterBy` maps `recipientId`/`idempotencyKey` to columns. */\n  private firstByIdempotencyKey(recipientId: Id, idempotencyKey: string): Promise<TModel | null> {\n    return this.first({ recipientId, idempotencyKey } as NotificationsFilter);\n  }\n}\n"],"mappings":"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"}