@hollware/server 0.3.0 → 0.5.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.
@@ -135,6 +135,11 @@ const tokens = sqliteTable("tokens", {
135
135
  userId: text("user_id").notNull(),
136
136
  label: text("label").notNull(),
137
137
  hash: text("hash").notNull(),
138
+ /**
139
+ * `personal` (`hw_pat_`) or `read` (`hw_read_`), which reads the published content of the
140
+ * site over the read API and nothing else. Its CHECK is the column's own (migration 0007).
141
+ */
142
+ kind: text("kind", { enum: ["personal", "read"] }).notNull().default("personal"),
138
143
  /** A JSON array of scopes. */
139
144
  scopes: text("scopes").notNull(),
140
145
  expiresAt: text("expires_at"),
@@ -413,10 +418,12 @@ const deliveries = sqliteTable("deliveries", {
413
418
  }),
414
419
  check("deliveries_attempts", sql`${t.attempts} >= 0`)
415
420
  ]);
416
- /** The passkeys of a person (ADR-026): WebAuthn credentials, by their id, with their public key. */
421
+ /**
422
+ * The passkeys of a person (ADR-026): WebAuthn credentials, by their id, with their public key. A
423
+ * person is one across the sites of a deployment (v0.4), and so are their passkeys and sessions.
424
+ */
417
425
  const passkeys = sqliteTable("passkeys", {
418
426
  id: id("pky"),
419
- siteId: siteId(),
420
427
  userId: text("user_id").notNull(),
421
428
  /** The credential id, base64url, as the authenticator gives it. */
422
429
  credentialId: text("credential_id").notNull(),
@@ -430,14 +437,8 @@ const passkeys = sqliteTable("passkeys", {
430
437
  lastUsedAt: text("last_used_at"),
431
438
  ...timestamps()
432
439
  }, (t) => [
433
- unique("passkeys_id_site").on(t.id, t.siteId),
434
440
  unique("passkeys_credential").on(t.credentialId),
435
441
  index("passkeys_user").on(t.userId),
436
- foreignKey({
437
- name: "passkeys_site",
438
- columns: [t.siteId],
439
- foreignColumns: [sites.id]
440
- }),
441
442
  foreignKey({
442
443
  name: "passkeys_user_fk",
443
444
  columns: [t.userId],
@@ -479,7 +480,6 @@ const invitations = sqliteTable("invitations", {
479
480
  /** WebAuthn challenges, each used once and briefly (ADR-026). */
480
481
  const authChallenges = sqliteTable("auth_challenges", {
481
482
  id: id("chl"),
482
- siteId: siteId(),
483
483
  challenge: text("challenge").notNull(),
484
484
  /** `register` or `authenticate`. */
485
485
  purpose: text("purpose").notNull(),
@@ -489,37 +489,19 @@ const authChallenges = sqliteTable("auth_challenges", {
489
489
  userId: text("user_id"),
490
490
  expiresAt: text("expires_at").notNull(),
491
491
  ...timestamps()
492
- }, (t) => [
493
- unique("auth_challenges_id_site").on(t.id, t.siteId),
494
- foreignKey({
495
- name: "auth_challenges_site",
496
- columns: [t.siteId],
497
- foreignColumns: [sites.id]
498
- }),
499
- check("auth_challenges_purpose", sql`${t.purpose} IN ('register', 'authenticate')`)
500
- ]);
492
+ }, (t) => [check("auth_challenges_purpose", sql`${t.purpose} IN ('register', 'authenticate')`)]);
501
493
  /** Signed-in people (ADR-026): the session cookie that `/authorize` reads, by its hash. */
502
494
  const sessions = sqliteTable("sessions", {
503
495
  id: id("ses"),
504
- siteId: siteId(),
505
496
  userId: text("user_id").notNull(),
506
497
  hash: text("hash").notNull(),
507
498
  expiresAt: text("expires_at").notNull(),
508
499
  ...timestamps()
509
- }, (t) => [
510
- unique("sessions_id_site").on(t.id, t.siteId),
511
- unique("sessions_hash").on(t.hash),
512
- foreignKey({
513
- name: "sessions_site",
514
- columns: [t.siteId],
515
- foreignColumns: [sites.id]
516
- }),
517
- foreignKey({
518
- name: "sessions_user",
519
- columns: [t.userId],
520
- foreignColumns: [users.id]
521
- })
522
- ]);
500
+ }, (t) => [unique("sessions_hash").on(t.hash), foreignKey({
501
+ name: "sessions_user",
502
+ columns: [t.userId],
503
+ foreignColumns: [users.id]
504
+ })]);
523
505
  /**
524
506
  * Reviews (v0.3): a person who may write but not publish asks for a revision to be published, and
525
507
  * a person who may publish approves it, which publishes it, or asks for changes with a note. An
@@ -576,6 +558,47 @@ const reviews = sqliteTable("reviews", {
576
558
  check("reviews_decided", sql`(${t.status} IN ('pending', 'cancelled')) OR (${t.decidedBy} IS NOT NULL AND ${t.decidedAt} IS NOT NULL)`),
577
559
  check("reviews_json", sql`json_valid(${t.acknowledged}) AND json_type(${t.acknowledged}) = 'array' AND (${t.requestedMeta} IS NULL OR json_valid(${t.requestedMeta}))`)
578
560
  ]);
561
+ /**
562
+ * The vectors of an entry, as derived data (v0.5): the published revision they were made from,
563
+ * with which model and which version of the chunker, and how many pieces there are. The vectors
564
+ * live in Vectorize under `<entry id>:<piece>`; this row says which exist and whether they are
565
+ * current. One row per entry: a new publication replaces it.
566
+ */
567
+ const entryEmbeddings = sqliteTable("entry_embeddings", {
568
+ id: id("emb"),
569
+ siteId: siteId(),
570
+ entryId: text("entry_id").notNull(),
571
+ revisionId: text("revision_id").notNull(),
572
+ model: text("model").notNull(),
573
+ chunkerVersion: text("chunker_version").notNull(),
574
+ /** How many pieces, so how many vectors: `<entry id>:0` to `<entry id>:<pieces - 1>`. */
575
+ pieces: integer("pieces").notNull(),
576
+ /**
577
+ * The entries closest to this one by their summaries, in its collection and locale, closest
578
+ * first, as JSON: `[{ "id": "ent_…", "score": 0.91 }]`. Found when its vectors were made.
579
+ */
580
+ related: text("related").notNull().default("[]"),
581
+ ...timestamps()
582
+ }, (t) => [
583
+ unique("entry_embeddings_id_site").on(t.id, t.siteId),
584
+ unique("entry_embeddings_entry").on(t.siteId, t.entryId),
585
+ foreignKey({
586
+ name: "entry_embeddings_site",
587
+ columns: [t.siteId],
588
+ foreignColumns: [sites.id]
589
+ }),
590
+ foreignKey({
591
+ name: "entry_embeddings_entry_fk",
592
+ columns: [t.entryId, t.siteId],
593
+ foreignColumns: [entries.id, entries.siteId]
594
+ }).onDelete("cascade"),
595
+ foreignKey({
596
+ name: "entry_embeddings_revision_fk",
597
+ columns: [t.revisionId, t.siteId],
598
+ foreignColumns: [revisions.id, revisions.siteId]
599
+ }),
600
+ check("entry_embeddings_pieces", sql`${t.pieces} >= 1`)
601
+ ]);
579
602
  //#endregion
580
603
  //#region src/identity/secrets.ts
581
604
  /**
@@ -607,7 +630,7 @@ function userHandle(userId) {
607
630
  //#region src/identity/store.ts
608
631
  function roleOf(value) {
609
632
  if (value === "owner" || value === "editor" || value === "author") return value;
610
- throw new Error(`An invitation has the role ${value}, which the schema does not allow`);
633
+ throw new Error(`A role of ${value} is no role the schema allows`);
611
634
  }
612
635
  /** Creates a link that lets one person in, and gives its secret, which is never stored. */
613
636
  async function createInvitation(db, input) {
@@ -633,24 +656,25 @@ async function createInvitation(db, input) {
633
656
  };
634
657
  }
635
658
  /** The invitation of a secret, while it is unused and has not expired. */
636
- async function findInvitation(db, site, secret, now) {
659
+ async function findInvitation(db, secret, now) {
637
660
  const [row] = await db.select({
638
661
  id: invitations.id,
662
+ site: invitations.siteId,
639
663
  role: invitations.role,
640
664
  userId: invitations.userId
641
- }).from(invitations).where(and(eq(invitations.siteId, site), eq(invitations.hash, await hashSecret(secret)), isNull(invitations.usedAt), gt(invitations.expiresAt, now.toISOString()))).limit(1);
665
+ }).from(invitations).where(and(eq(invitations.hash, await hashSecret(secret)), isNull(invitations.usedAt), gt(invitations.expiresAt, now.toISOString()))).limit(1);
642
666
  return row === void 0 ? null : {
643
667
  ...row,
644
668
  role: roleOf(row.role)
645
669
  };
646
670
  }
647
671
  /** Uses an invitation, once: `false` when it was used already or has expired. */
648
- async function redeemInvitation(db, site, id, now) {
672
+ async function redeemInvitation(db, id, now) {
649
673
  const at = now.toISOString();
650
674
  return (await db.update(invitations).set({
651
675
  usedAt: at,
652
676
  updatedAt: at
653
- }).where(and(eq(invitations.siteId, site), eq(invitations.id, id), isNull(invitations.usedAt), gt(invitations.expiresAt, at))).returning({ id: invitations.id })).length === 1;
677
+ }).where(and(eq(invitations.id, id), isNull(invitations.usedAt), gt(invitations.expiresAt, at))).returning({ id: invitations.id })).length === 1;
654
678
  }
655
679
  /** Keeps a WebAuthn challenge for five minutes. */
656
680
  async function saveChallenge(db, input) {
@@ -658,7 +682,6 @@ async function saveChallenge(db, input) {
658
682
  const now = input.now.toISOString();
659
683
  await db.insert(authChallenges).values({
660
684
  id,
661
- siteId: input.site,
662
685
  challenge: input.challenge,
663
686
  purpose: input.purpose,
664
687
  invitationId: input.invitationId,
@@ -671,16 +694,16 @@ async function saveChallenge(db, input) {
671
694
  }
672
695
  /** Takes a challenge, once, if it has the purpose asked and has not expired. */
673
696
  async function takeChallenge(db, input) {
674
- const [row] = await db.delete(authChallenges).where(and(eq(authChallenges.siteId, input.site), eq(authChallenges.id, input.id))).returning();
697
+ const [row] = await db.delete(authChallenges).where(eq(authChallenges.id, input.id)).returning();
675
698
  if (row === void 0 || row.purpose !== input.purpose) return null;
676
699
  if (row.expiresAt <= input.now.toISOString()) return null;
677
700
  return row;
678
701
  }
679
- async function passkeysOf(db, site, userId) {
680
- return db.select().from(passkeys).where(and(eq(passkeys.siteId, site), eq(passkeys.userId, userId)));
702
+ async function passkeysOf(db, userId) {
703
+ return db.select().from(passkeys).where(eq(passkeys.userId, userId));
681
704
  }
682
- async function findPasskey(db, site, credentialId) {
683
- const [row] = await db.select().from(passkeys).where(and(eq(passkeys.siteId, site), eq(passkeys.credentialId, credentialId))).limit(1);
705
+ async function findPasskey(db, credentialId) {
706
+ const [row] = await db.select().from(passkeys).where(eq(passkeys.credentialId, credentialId)).limit(1);
684
707
  return row ?? null;
685
708
  }
686
709
  /** Records a sign-in with a passkey: its new counter, and when. */
@@ -714,7 +737,6 @@ async function addPasskey(db, input) {
714
737
  const at = input.now.toISOString();
715
738
  await db.insert(passkeys).values({
716
739
  id: newId("pky"),
717
- siteId: input.site,
718
740
  userId: input.userId,
719
741
  credentialId: input.credentialId,
720
742
  publicKey: input.publicKey,
@@ -733,7 +755,6 @@ async function createSession(db, input) {
733
755
  const expiresAt = new Date(input.now.getTime() + input.hours * 36e5).toISOString();
734
756
  await db.insert(sessions).values({
735
757
  id: newId("ses"),
736
- siteId: input.site,
737
758
  userId: input.userId,
738
759
  hash: await hashSecret(secret),
739
760
  expiresAt,
@@ -745,26 +766,36 @@ async function createSession(db, input) {
745
766
  expiresAt
746
767
  };
747
768
  }
748
- /** The person of a session cookie, while it lasts, with their role in its site now. */
749
- async function findSession(db, site, secret, now) {
769
+ /** The person of a session cookie, while it lasts. */
770
+ async function findSession(db, secret, now) {
750
771
  const [row] = await db.select({
751
772
  userId: sessions.userId,
752
- name: users.name,
753
- role: members.role
754
- }).from(sessions).innerJoin(users, eq(users.id, sessions.userId)).innerJoin(members, and(eq(members.userId, sessions.userId), eq(members.siteId, sessions.siteId))).where(and(eq(sessions.siteId, site), eq(sessions.hash, await hashSecret(secret)), gt(sessions.expiresAt, now.toISOString()))).limit(1);
773
+ name: users.name
774
+ }).from(sessions).innerJoin(users, eq(users.id, sessions.userId)).where(and(eq(sessions.hash, await hashSecret(secret)), gt(sessions.expiresAt, now.toISOString()))).limit(1);
755
775
  return row ?? null;
756
776
  }
777
+ /** The sites a person is a member of, with their role in each, in the order they joined. */
778
+ async function membershipsOf(db, userId) {
779
+ return (await db.select({
780
+ site: members.siteId,
781
+ role: members.role
782
+ }).from(members).where(eq(members.userId, userId)).orderBy(members.createdAt, members.siteId)).map((row) => ({
783
+ site: row.site,
784
+ role: roleOf(row.role)
785
+ }));
786
+ }
757
787
  /** An invitation by its id, used or not: what a registration redeems. */
758
- async function invitationById(db, site, id) {
788
+ async function invitationById(db, id) {
759
789
  const [row] = await db.select({
760
790
  id: invitations.id,
791
+ site: invitations.siteId,
761
792
  role: invitations.role,
762
793
  userId: invitations.userId
763
- }).from(invitations).where(and(eq(invitations.siteId, site), eq(invitations.id, id))).limit(1);
794
+ }).from(invitations).where(eq(invitations.id, id)).limit(1);
764
795
  return row === void 0 ? null : {
765
796
  ...row,
766
797
  role: roleOf(row.role)
767
798
  };
768
799
  }
769
800
  //#endregion
770
- export { isRole as A, revisions as C, users as D, tokens as E, ROLE_SCOPES as O, revisionTags as S, sites as T, invitations as _, findInvitation as a, publications as b, invitationById as c, saveChallenge as d, takeChallenge as f, entries as g, deliveries as h, createUser as i, effectiveScopes as k, passkeysOf as l, userHandle as m, createInvitation as n, findPasskey as o, touchPasskey as p, createSession as r, findSession as s, addPasskey as t, redeemInvitation as u, members as v, schedules as w, reviews as x, outbox as y };
801
+ export { ROLE_SCOPES as A, reviews as C, sites as D, schedules as E, isRole as M, tokens as O, publications as S, revisions as T, entries as _, findInvitation as a, members as b, invitationById as c, redeemInvitation as d, saveChallenge as f, deliveries as g, userHandle as h, createUser as i, effectiveScopes as j, users as k, membershipsOf as l, touchPasskey as m, createInvitation as n, findPasskey as o, takeChallenge as p, createSession as r, findSession as s, addPasskey as t, passkeysOf as u, entryEmbeddings as v, revisionTags as w, outbox as x, invitations as y };
@@ -0,0 +1,54 @@
1
+ -- Edited by hand (ADR-023): a person is one across the sites of a deployment (v0.4), so passkeys,
2
+ -- sessions and sign-in challenges lose site_id. Each table is rebuilt, which D1 allows here since
3
+ -- no other table points at them; the PRAGMA foreign_keys lines drizzle-kit writes are gone, as D1
4
+ -- ignores them, and the CHECKs name their columns without the temporary table's name.
5
+ CREATE TABLE `__new_auth_challenges` (
6
+ `id` text PRIMARY KEY NOT NULL,
7
+ `challenge` text NOT NULL,
8
+ `purpose` text NOT NULL,
9
+ `invitation_id` text,
10
+ `user_id` text,
11
+ `expires_at` text NOT NULL,
12
+ `created_at` text NOT NULL,
13
+ `updated_at` text NOT NULL,
14
+ CONSTRAINT "auth_challenges_purpose" CHECK("purpose" IN ('register', 'authenticate'))
15
+ );
16
+ --> statement-breakpoint
17
+ INSERT INTO `__new_auth_challenges`("id", "challenge", "purpose", "invitation_id", "user_id", "expires_at", "created_at", "updated_at") SELECT "id", "challenge", "purpose", "invitation_id", "user_id", "expires_at", "created_at", "updated_at" FROM `auth_challenges`;--> statement-breakpoint
18
+ DROP TABLE `auth_challenges`;--> statement-breakpoint
19
+ ALTER TABLE `__new_auth_challenges` RENAME TO `auth_challenges`;--> statement-breakpoint
20
+ CREATE TABLE `__new_passkeys` (
21
+ `id` text PRIMARY KEY NOT NULL,
22
+ `user_id` text NOT NULL,
23
+ `credential_id` text NOT NULL,
24
+ `public_key` text NOT NULL,
25
+ `counter` integer NOT NULL,
26
+ `transports` text NOT NULL,
27
+ `label` text NOT NULL,
28
+ `last_used_at` text,
29
+ `created_at` text NOT NULL,
30
+ `updated_at` text NOT NULL,
31
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
32
+ CONSTRAINT "passkeys_counter" CHECK("counter" >= 0),
33
+ CONSTRAINT "passkeys_json" CHECK(json_valid("transports") AND json_type("transports") = 'array')
34
+ );
35
+ --> statement-breakpoint
36
+ INSERT INTO `__new_passkeys`("id", "user_id", "credential_id", "public_key", "counter", "transports", "label", "last_used_at", "created_at", "updated_at") SELECT "id", "user_id", "credential_id", "public_key", "counter", "transports", "label", "last_used_at", "created_at", "updated_at" FROM `passkeys`;--> statement-breakpoint
37
+ DROP TABLE `passkeys`;--> statement-breakpoint
38
+ ALTER TABLE `__new_passkeys` RENAME TO `passkeys`;--> statement-breakpoint
39
+ CREATE INDEX `passkeys_user` ON `passkeys` (`user_id`);--> statement-breakpoint
40
+ CREATE UNIQUE INDEX `passkeys_credential` ON `passkeys` (`credential_id`);--> statement-breakpoint
41
+ CREATE TABLE `__new_sessions` (
42
+ `id` text PRIMARY KEY NOT NULL,
43
+ `user_id` text NOT NULL,
44
+ `hash` text NOT NULL,
45
+ `expires_at` text NOT NULL,
46
+ `created_at` text NOT NULL,
47
+ `updated_at` text NOT NULL,
48
+ FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
49
+ );
50
+ --> statement-breakpoint
51
+ INSERT INTO `__new_sessions`("id", "user_id", "hash", "expires_at", "created_at", "updated_at") SELECT "id", "user_id", "hash", "expires_at", "created_at", "updated_at" FROM `sessions`;--> statement-breakpoint
52
+ DROP TABLE `sessions`;--> statement-breakpoint
53
+ ALTER TABLE `__new_sessions` RENAME TO `sessions`;--> statement-breakpoint
54
+ CREATE UNIQUE INDEX `sessions_hash` ON `sessions` (`hash`);
@@ -0,0 +1,4 @@
1
+ -- Edited by hand (ADR-023). Read tokens (v0.4): a token is personal (hw_pat_) or a read token (hw_read_), which reads the
2
+ -- published content of its site over the read API and nothing else. Every token so far is
3
+ -- personal. The CHECK is the column's own, so the table is not rebuilt.
4
+ ALTER TABLE `tokens` ADD `kind` text DEFAULT 'personal' NOT NULL CHECK (`kind` IN ('personal', 'read'));
@@ -0,0 +1,47 @@
1
+ -- Written by hand (ADR-023), as drizzle-kit has no virtual tables. Full-text search (v0.5): an
2
+ -- FTS5 index of the published revision of each entry, its title, its tags and its text. The text
3
+ -- is taken from the stored Comark document, the strings at the text positions of its nodes, so
4
+ -- the Markdown's own marks are never indexed. `unicode61 remove_diacritics 2` matches
5
+ -- "migracion" with "migración".
6
+ --
7
+ -- The triggers keep it in the step that changes what is published, whichever write does it:
8
+ -- publish, a group, a schedule that comes due, unpublish. Only published content is in it.
9
+ CREATE VIRTUAL TABLE `entries_fts` USING fts5(
10
+ `title`,
11
+ `tags`,
12
+ `body`,
13
+ `entry_id` UNINDEXED,
14
+ `site_id` UNINDEXED,
15
+ `collection` UNINDEXED,
16
+ `locale` UNINDEXED,
17
+ tokenize = 'unicode61 remove_diacritics 2'
18
+ );
19
+ --> statement-breakpoint
20
+ CREATE TRIGGER `entries_fts_publication` AFTER UPDATE OF `published_rev` ON `entries`
21
+ BEGIN
22
+ DELETE FROM `entries_fts` WHERE `entry_id` = OLD.`id`;
23
+ INSERT INTO `entries_fts` (`title`, `tags`, `body`, `entry_id`, `site_id`, `collection`, `locale`)
24
+ SELECT
25
+ coalesce(json_extract(r.`frontmatter`, '$.title'), ''),
26
+ coalesce((SELECT group_concat(t.`tag`, ' ') FROM `revision_tags` t WHERE t.`revision_id` = r.`id`), ''),
27
+ coalesce((SELECT group_concat(n.`value`, ' ') FROM json_tree(r.`document`, '$.nodes') n
28
+ WHERE n.`type` = 'text' AND typeof(n.`key`) = 'integer' AND n.`key` >= 2), ''),
29
+ NEW.`id`, NEW.`site_id`, NEW.`collection`, NEW.`locale`
30
+ FROM `revisions` r
31
+ WHERE NEW.`published_rev` IS NOT NULL AND r.`id` = NEW.`published_rev`;
32
+ END;
33
+ --> statement-breakpoint
34
+ CREATE TRIGGER `entries_fts_removal` AFTER DELETE ON `entries`
35
+ BEGIN
36
+ DELETE FROM `entries_fts` WHERE `entry_id` = OLD.`id`;
37
+ END;
38
+ --> statement-breakpoint
39
+ -- What is published already.
40
+ INSERT INTO `entries_fts` (`title`, `tags`, `body`, `entry_id`, `site_id`, `collection`, `locale`)
41
+ SELECT
42
+ coalesce(json_extract(r.`frontmatter`, '$.title'), ''),
43
+ coalesce((SELECT group_concat(t.`tag`, ' ') FROM `revision_tags` t WHERE t.`revision_id` = r.`id`), ''),
44
+ coalesce((SELECT group_concat(n.`value`, ' ') FROM json_tree(r.`document`, '$.nodes') n
45
+ WHERE n.`type` = 'text' AND typeof(n.`key`) = 'integer' AND n.`key` >= 2), ''),
46
+ e.`id`, e.`site_id`, e.`collection`, e.`locale`
47
+ FROM `entries` e JOIN `revisions` r ON r.`id` = e.`published_rev`;
@@ -0,0 +1,19 @@
1
+ CREATE TABLE `entry_embeddings` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `site_id` text NOT NULL,
4
+ `entry_id` text NOT NULL,
5
+ `revision_id` text NOT NULL,
6
+ `model` text NOT NULL,
7
+ `chunker_version` text NOT NULL,
8
+ `pieces` integer NOT NULL,
9
+ `related` text DEFAULT '[]' NOT NULL,
10
+ `created_at` text NOT NULL,
11
+ `updated_at` text NOT NULL,
12
+ FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE no action,
13
+ FOREIGN KEY (`entry_id`,`site_id`) REFERENCES `entries`(`id`,`site_id`) ON UPDATE no action ON DELETE cascade,
14
+ FOREIGN KEY (`revision_id`,`site_id`) REFERENCES `revisions`(`id`,`site_id`) ON UPDATE no action ON DELETE no action,
15
+ CONSTRAINT "entry_embeddings_pieces" CHECK("entry_embeddings"."pieces" >= 1)
16
+ );
17
+ --> statement-breakpoint
18
+ CREATE UNIQUE INDEX `entry_embeddings_id_site` ON `entry_embeddings` (`id`,`site_id`);--> statement-breakpoint
19
+ CREATE UNIQUE INDEX `entry_embeddings_entry` ON `entry_embeddings` (`site_id`,`entry_id`);