All files / web/src/db/schema abacus-designs.ts

100% Statements 80/80
100% Branches 4/4
100% Functions 1/1
100% Lines 80/80

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 812x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 53x 53x 2x 2x 2x 2x  
import { createId } from '@paralleldrive/cuid2'
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
import type { AbacusDesignProvenance, AbacusDesignSnapshot } from '@/lib/abacus/design-snapshot'
import { users } from './users'
 
/**
 * Persisted abacus design snapshots (Gitea #22).
 *
 * One row per (owner, design content): the POST route hashes the owner id +
 * the canonical serialization of the `{v, params, overrides, profileId}`
 * envelope and upserts on `contentHash`, so identical submits (and submit
 * retries) by the same user converge on one id. The hash is OWNER-scoped on
 * purpose: an unshared design reads owner-or-admin, so a global dedup would
 * hand user B a design id owned by whichever user A happened to print the same
 * design first — and B could never read it back, nor share or revoke it
 * (#24, which makes the scoping load-bearing rather than merely tidy). Rows
 * are permanent — a
 * `?design=<id>` link printed on a THH job card must keep resolving years
 * later (operator decision, 2026-07-28), so nothing here expires or
 * cascades away with its creator.
 *
 * `design` is the restorable envelope, re-parsed through
 * `parseDesignSnapshot` on every read. `provenance` is the submit-time AMS
 * projection (filamentMap/slotLabels) kept for the record only — it is never
 * returned by the GET route and never rehydrated.
 */
export const abacusDesigns = sqliteTable(
  'abacus_designs',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => createId()),
    /** sha-256 hex of `canonicalDesignSnapshot(design)` — the dedup key. */
    contentHash: text('content_hash').notNull().unique(),
    design: text('design', { mode: 'json' }).$type<AbacusDesignSnapshot>().notNull(),
    provenance: text('provenance', { mode: 'json' }).$type<AbacusDesignProvenance>(),
    /** Snapshot owner (guest-first — guests can print, so guests snapshot).
     *  Nulled if the user row goes away; the design itself is kept. */
    createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
    createdAt: integer('created_at', { mode: 'timestamp' })
      .notNull()
      .$defaultFn(() => new Date()),
    views: integer('views').notNull().default(0),
    lastAccessedAt: integer('last_accessed_at', { mode: 'timestamp' }),
    /** Cross-account read access (#24). NULL = private (owner-or-admin, the
     *  #22 default); a timestamp = anyone with the link may read it.
     *  Deliberately on the design row rather than in a share-code table: the
     *  id IS the capability (cuid2, far stronger than a 7-char share code),
     *  and links already stamped on THH job cards must be repairable by a
     *  later share — a new code at a new URL could never fix them. Revoking
     *  nulls this back out, and re-sharing revives the SAME url, which is what
     *  makes the toggle its own undo. Access changes; content never does. */
    sharedAt: integer('shared_at', { mode: 'timestamp' }),
    /** What the owner calls this design (#11). NULL = never named, and the UI
     *  falls back to the engraved text, then to the column count.
     *
     *  Metadata, NOT content: `name` and `hiddenAt` are both outside
     *  `contentHash`, which covers only the `{v, params, overrides, profileId}`
     *  envelope. That is load-bearing — if a name entered the hash, renaming
     *  would fork the design and orphan every `?design=` link already printed
     *  on a job card. The flip side is that one snapshot carries exactly one
     *  name: building the same design twice under two names dedups onto the
     *  first row (operator decision 2026-07-29, over a saved-designs join
     *  table). */
    name: text('name'),
    /** Taken out of the owner's "my abacuses" list (#11) — the honest form of
     *  the delete this ticket withdrew. The row itself is permanent, so every
     *  printed link keeps resolving; only the list forgets it. A SHARED design
     *  is listed even when this is set (see the collection route): hiding a
     *  public design would reopen the revocation hole #24 exists to close, so
     *  un-sharing is what finally lets it go. */
    hiddenAt: integer('hidden_at', { mode: 'timestamp' }),
  },
  (table) => ({
    createdByIdx: index('abacus_designs_created_by_idx').on(table.createdBy),
  })
)
 
export type AbacusDesign = typeof abacusDesigns.$inferSelect
export type NewAbacusDesign = typeof abacusDesigns.$inferInsert