@manablox/db 0.3.0 → 0.4.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,6 +1,6 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { relations, sql } from "drizzle-orm";
3
- import { boolean, customType, index, integer, jsonb, pgTable, primaryKey, text, timestamp, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
3
+ import { bigserial, boolean, customType, index, integer, jsonb, pgTable, primaryKey, text, timestamp, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
4
4
  //#region src/columns.ts
5
5
  /**
6
6
  * `ltree` — the materialised ancestor path of a content node. A subtree move is one
@@ -193,6 +193,44 @@ const assetUsages = pgTable("asset_usages", {
193
193
  index("asset_usages_content_idx").on(table.contentId)
194
194
  ]);
195
195
  //#endregion
196
+ //#region src/schema/audit.ts
197
+ /**
198
+ * The audit log: one row per action, appended and never changed.
199
+ *
200
+ * Nothing here references another table. A space, a user or a document may be deleted
201
+ * long after the row was written, and the row must still say who did what: the actor
202
+ * and the target are recorded by id *and* by label, as they were at the time. A trigger
203
+ * (see the migration) refuses every `update` and `delete`, and each row carries the
204
+ * SHA-256 of its content chained to the previous row's, so a gap or an edit shows up
205
+ * when the chain is verified.
206
+ */
207
+ const auditEntries = pgTable("audit_entries", {
208
+ id: uuid().primaryKey().defaultRandom(),
209
+ /** The order of the chain; assigned by the database, gapless within a transaction. */
210
+ seq: bigserial({ mode: "number" }).notNull(),
211
+ at: timestamp({ withTimezone: true }).notNull().defaultNow(),
212
+ /** Null for an instance-wide action: an account, a key, creating a space. */
213
+ spaceId: uuid(),
214
+ actorKind: text().$type().notNull(),
215
+ actorId: text(),
216
+ actorLabel: text().notNull(),
217
+ actorDetail: jsonb().$type(),
218
+ action: text().$type().notNull(),
219
+ targetKind: text().$type().notNull(),
220
+ targetId: text(),
221
+ targetLabel: text(),
222
+ changes: jsonb().$type().notNull().default(sql`'[]'::jsonb`),
223
+ meta: jsonb().$type(),
224
+ prevHash: text(),
225
+ hash: text().notNull()
226
+ }, (table) => [
227
+ index("audit_entries_seq_idx").on(table.seq),
228
+ index("audit_entries_space_at_idx").on(table.spaceId, table.at),
229
+ index("audit_entries_target_idx").on(table.targetKind, table.targetId),
230
+ index("audit_entries_actor_idx").on(table.actorKind, table.actorId),
231
+ index("audit_entries_action_idx").on(table.action)
232
+ ]);
233
+ //#endregion
196
234
  //#region src/schema/auth.ts
197
235
  const users = pgTable("users", {
198
236
  id: uuid().primaryKey().defaultRandom(),
@@ -204,6 +242,11 @@ const users = pgTable("users", {
204
242
  role: text().notNull().default("editor"),
205
243
  banned: boolean().notNull().default(false),
206
244
  banReason: text(),
245
+ /**
246
+ * Per notification kind, the channels this person switched on or off. Only what
247
+ * differs from the catalogue's defaults is stored; see `resolveNotificationPreferences`.
248
+ */
249
+ notificationPreferences: jsonb().$type().notNull().default(sql`'{}'::jsonb`),
207
250
  createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
208
251
  updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow()
209
252
  }, (table) => [uniqueIndex("users_email_key").on(table.email)]);
@@ -309,6 +352,7 @@ const contentTypes = pgTable("content_types", {
309
352
  isPublishable: boolean().notNull().default(true),
310
353
  isVisibleInTree: boolean().notNull().default(true),
311
354
  canBeVisibleInMenu: boolean().notNull().default(true),
355
+ requiresApproval: boolean().notNull().default(false),
312
356
  fields: jsonb().$type().notNull().default(sql`'[]'::jsonb`),
313
357
  createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
314
358
  updatedAt: timestamp({ withTimezone: true }).notNull().defaultNow()
@@ -349,6 +393,68 @@ const menuItems = pgTable("menu_items", {
349
393
  url: text()
350
394
  }, (table) => [index("menu_items_menu_idx").on(table.menuId, table.parentId, table.position), index("menu_items_localization_idx").on(table.localizationId)]);
351
395
  //#endregion
396
+ //#region src/schema/notifications.ts
397
+ /**
398
+ * One notification, for one person. A single event that concerns five people is five
399
+ * rows, so each can be read, kept or dropped on its own; the fan-out is the service's.
400
+ *
401
+ * The row is the in-app copy. Email and push are sent at the same time and leave no
402
+ * row of their own: a notification the person turned off in the admin but kept by
403
+ * email is a mail, not a row here.
404
+ */
405
+ const notifications = pgTable("notifications", {
406
+ id: uuid().primaryKey().defaultRandom(),
407
+ userId: uuid().notNull().references(() => users.id, { onDelete: "cascade" }),
408
+ /** Null for something that concerns the instance rather than a space. */
409
+ spaceId: uuid().references(() => spaces.id, { onDelete: "cascade" }),
410
+ kind: text().$type().notNull(),
411
+ title: text().notNull(),
412
+ body: text().notNull().default(""),
413
+ /** Where to go in the admin, as a path: `/content/<id>`. */
414
+ url: text(),
415
+ /** What it is about, so the inbox can group and a target's later fate can be shown. */
416
+ targetKind: text(),
417
+ targetId: text(),
418
+ /**
419
+ * Who caused it; `null` for the system. Text rather than a uuid, like the audit
420
+ * log's actor: it is a snapshot of whoever acted, not a reference.
421
+ */
422
+ actorId: text(),
423
+ actorLabel: text(),
424
+ meta: jsonb().$type(),
425
+ readAt: timestamp({ withTimezone: true }),
426
+ createdAt: timestamp({ withTimezone: true }).notNull().defaultNow()
427
+ }, (table) => [
428
+ index("notifications_user_created_idx").on(table.userId, table.createdAt),
429
+ index("notifications_user_unread_idx").on(table.userId, table.readAt),
430
+ index("notifications_target_idx").on(table.targetKind, table.targetId)
431
+ ]);
432
+ /**
433
+ * A request to publish a document on the author's behalf, and what became of it. One
434
+ * document may have several over its life (sent back, resubmitted), but at most one
435
+ * pending at a time; the service keeps that rule, the table keeps the history.
436
+ *
437
+ * Cascades with the document: a deleted draft has nothing left to approve.
438
+ */
439
+ const contentApprovals = pgTable("content_approvals", {
440
+ id: uuid().primaryKey().defaultRandom(),
441
+ spaceId: uuid().notNull().references(() => spaces.id, { onDelete: "cascade" }),
442
+ contentId: uuid().notNull().references(() => contents.id, { onDelete: "cascade" }),
443
+ typeId: uuid().notNull(),
444
+ status: text().$type().notNull().default("pending"),
445
+ /** Who asked; kept even once the account is gone, so the history stays readable. */
446
+ requestedBy: uuid(),
447
+ requestedByLabel: text().notNull().default(""),
448
+ requestNote: text(),
449
+ /** The document's version when it was submitted, so a reviewer sees what changed since. */
450
+ contentVersion: integer(),
451
+ requestedAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
452
+ decidedBy: uuid(),
453
+ decidedByLabel: text(),
454
+ decisionNote: text(),
455
+ decidedAt: timestamp({ withTimezone: true })
456
+ }, (table) => [index("content_approvals_content_idx").on(table.contentId, table.requestedAt), index("content_approvals_space_status_idx").on(table.spaceId, table.status, table.requestedAt)]);
457
+ //#endregion
352
458
  //#region src/schema/relations.ts
353
459
  const spacesRelations = relations(spaces, ({ many }) => ({
354
460
  contents: many(contents),
@@ -510,6 +616,8 @@ var schema_exports = /* @__PURE__ */ __exportAll({
510
616
  assetVariantsRelations: () => assetVariantsRelations,
511
617
  assets: () => assets,
512
618
  assetsRelations: () => assetsRelations,
619
+ auditEntries: () => auditEntries,
620
+ contentApprovals: () => contentApprovals,
513
621
  contentTypes: () => contentTypes,
514
622
  contentVersions: () => contentVersions,
515
623
  contents: () => contents,
@@ -520,6 +628,7 @@ var schema_exports = /* @__PURE__ */ __exportAll({
520
628
  menuItemsRelations: () => menuItemsRelations,
521
629
  menus: () => menus,
522
630
  menusRelations: () => menusRelations,
631
+ notifications: () => notifications,
523
632
  publishedContents: () => publishedContents,
524
633
  pushSubscriptions: () => pushSubscriptions,
525
634
  roles: () => roles,
@@ -536,4 +645,4 @@ var schema_exports = /* @__PURE__ */ __exportAll({
536
645
  workflows: () => workflows
537
646
  });
538
647
  //#endregion
539
- export { contentVersions as A, tsvector as B, roles as C, assetUsages as D, verifications as E, idToLabel as F, labelToId as I, ltree as L, publishedContents as M, spaces as N, assetVariants as O, buildPath as P, parsePath as R, memberships as S, users as T, menuItems as _, webhookDeliveries as a, accounts as b, assetVariantsRelations as c, membershipsRelations as d, menuItemsRelations as f, usersRelations as g, spacesRelations as h, workflows as i, contents as j, assets as k, assetsRelations as l, rolesRelations as m, pushSubscriptions as n, webhooks as o, menusRelations as p, workflowRuns as r, assetUsagesRelations as s, schema_exports as t, contentsRelations as u, menus as v, sessions as w, apikeys as x, contentTypes as y, pathDepth as z };
648
+ export { assetUsages as A, ltree as B, apikeys as C, users as D, sessions as E, publishedContents as F, pathDepth as H, spaces as I, buildPath as L, assets as M, contentVersions as N, verifications as O, contents as P, idToLabel as R, accounts as S, roles as T, tsvector as U, parsePath as V, contentApprovals as _, webhookDeliveries as a, menus as b, assetVariantsRelations as c, membershipsRelations as d, menuItemsRelations as f, usersRelations as g, spacesRelations as h, workflows as i, assetVariants as j, auditEntries as k, assetsRelations as l, rolesRelations as m, pushSubscriptions as n, webhooks as o, menusRelations as p, workflowRuns as r, assetUsagesRelations as s, schema_exports as t, contentsRelations as u, notifications as v, memberships as w, contentTypes as x, menuItems as y, labelToId as z };
package/dist/schema.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as menusRelations, B as accounts, C as spaces, D as contentsRelations, E as assetsRelations, F as menus, G as users, H as memberships, I as contentTypes, J as assetVariants, K as verifications, L as contentVersions, M as spacesRelations, N as usersRelations, O as membershipsRelations, P as menuItems, R as contents, S as webhooks, T as assetVariantsRelations, U as roles, V as apikeys, W as sessions, Y as assets, a as ContentRow, b as workflows, c as MembershipRow, d as PushSubscriptionRow, f as RoleRow, g as WorkflowRunRow, h as WorkflowRow, i as ContentInsert, j as rolesRelations, k as menuItemsRelations, l as MenuItemRow, m as UserRow, n as AssetUsageRow, o as ContentTypeRow, p as SpaceRow, q as assetUsages, r as AssetVariantRow, s as ContentVersionRow, t as AssetRow, u as MenuRow, v as pushSubscriptions, w as assetUsagesRelations, x as webhookDeliveries, y as workflowRuns, z as publishedContents } from "./index-rZ24t-Ln.js";
2
- export { AssetRow, AssetUsageRow, AssetVariantRow, ContentInsert, ContentRow, ContentTypeRow, ContentVersionRow, MembershipRow, MenuItemRow, MenuRow, PushSubscriptionRow, RoleRow, SpaceRow, UserRow, WorkflowRow, WorkflowRunRow, accounts, apikeys, assetUsages, assetUsagesRelations, assetVariants, assetVariantsRelations, assets, assetsRelations, contentTypes, contentVersions, contents, contentsRelations, memberships, membershipsRelations, menuItems, menuItemsRelations, menus, menusRelations, publishedContents, pushSubscriptions, roles, rolesRelations, sessions, spaces, spacesRelations, users, usersRelations, verifications, webhookDeliveries, webhooks, workflowRuns, workflows };
1
+ import { $ as assetUsages, A as contentsRelations, B as menus, C as workflows, D as assetUsagesRelations, E as spaces, F as spacesRelations, G as accounts, H as contentVersions, I as usersRelations, J as roles, K as apikeys, L as contentApprovals, M as menuItemsRelations, N as menusRelations, O as assetVariantsRelations, P as rolesRelations, Q as auditEntries, R as notifications, S as workflowRuns, T as webhooks, U as contents, V as contentTypes, W as publishedContents, X as users, Y as sessions, Z as verifications, _ as UserRow, a as ContentApprovalRow, c as ContentTypeRow, d as MenuItemRow, et as assetVariants, f as MenuRow, g as SpaceRow, h as RoleRow, i as AuditEntryRow, j as membershipsRelations, k as assetsRelations, l as ContentVersionRow, m as PushSubscriptionRow, n as AssetUsageRow, o as ContentInsert, p as NotificationRow, q as memberships, r as AssetVariantRow, s as ContentRow, t as AssetRow, tt as assets, u as MembershipRow, v as WorkflowRow, w as webhookDeliveries, x as pushSubscriptions, y as WorkflowRunRow, z as menuItems } from "./index-DrNMGM9N.js";
2
+ export { AssetRow, AssetUsageRow, AssetVariantRow, AuditEntryRow, ContentApprovalRow, ContentInsert, ContentRow, ContentTypeRow, ContentVersionRow, MembershipRow, MenuItemRow, MenuRow, NotificationRow, PushSubscriptionRow, RoleRow, SpaceRow, UserRow, WorkflowRow, WorkflowRunRow, accounts, apikeys, assetUsages, assetUsagesRelations, assetVariants, assetVariantsRelations, assets, assetsRelations, auditEntries, contentApprovals, contentTypes, contentVersions, contents, contentsRelations, memberships, membershipsRelations, menuItems, menuItemsRelations, menus, menusRelations, notifications, publishedContents, pushSubscriptions, roles, rolesRelations, sessions, spaces, spacesRelations, users, usersRelations, verifications, webhookDeliveries, webhooks, workflowRuns, workflows };
package/dist/schema.js CHANGED
@@ -1,2 +1,2 @@
1
- import { A as contentVersions, C as roles, D as assetUsages, E as verifications, M as publishedContents, N as spaces, O as assetVariants, S as memberships, T as users, _ as menuItems, a as webhookDeliveries, b as accounts, c as assetVariantsRelations, d as membershipsRelations, f as menuItemsRelations, g as usersRelations, h as spacesRelations, i as workflows, j as contents, k as assets, l as assetsRelations, m as rolesRelations, n as pushSubscriptions, o as webhooks, p as menusRelations, r as workflowRuns, s as assetUsagesRelations, u as contentsRelations, v as menus, w as sessions, x as apikeys, y as contentTypes } from "./schema-Bb4p16Yz.js";
2
- export { accounts, apikeys, assetUsages, assetUsagesRelations, assetVariants, assetVariantsRelations, assets, assetsRelations, contentTypes, contentVersions, contents, contentsRelations, memberships, membershipsRelations, menuItems, menuItemsRelations, menus, menusRelations, publishedContents, pushSubscriptions, roles, rolesRelations, sessions, spaces, spacesRelations, users, usersRelations, verifications, webhookDeliveries, webhooks, workflowRuns, workflows };
1
+ import { A as assetUsages, C as apikeys, D as users, E as sessions, F as publishedContents, I as spaces, M as assets, N as contentVersions, O as verifications, P as contents, S as accounts, T as roles, _ as contentApprovals, a as webhookDeliveries, b as menus, c as assetVariantsRelations, d as membershipsRelations, f as menuItemsRelations, g as usersRelations, h as spacesRelations, i as workflows, j as assetVariants, k as auditEntries, l as assetsRelations, m as rolesRelations, n as pushSubscriptions, o as webhooks, p as menusRelations, r as workflowRuns, s as assetUsagesRelations, u as contentsRelations, v as notifications, w as memberships, x as contentTypes, y as menuItems } from "./schema-Dm3RcBst.js";
2
+ export { accounts, apikeys, assetUsages, assetUsagesRelations, assetVariants, assetVariantsRelations, assets, assetsRelations, auditEntries, contentApprovals, contentTypes, contentVersions, contents, contentsRelations, memberships, membershipsRelations, menuItems, menuItemsRelations, menus, menusRelations, notifications, publishedContents, pushSubscriptions, roles, rolesRelations, sessions, spaces, spacesRelations, users, usersRelations, verifications, webhookDeliveries, webhooks, workflowRuns, workflows };
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { U as DatabaseHandle, t as Repositories } from "./index-Cyf_N5K3.js";
1
+ import { it as DatabaseHandle, t as Repositories } from "./index-BLAkQMJT.js";
2
2
  import { ContentTypeDefinition, ContentTypeRegistry } from "@manablox/core";
3
3
  //#region src/testing-fixtures.d.ts
4
4
  export declare const stringField: import("@manablox/core").FieldTypeDefinition<Record<string, unknown>, string>;
package/dist/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as applyBootstrapSql, g as createDatabase, t as createRepositories } from "./repositories-DYjzuuF6.js";
1
+ import { b as applyBootstrapSql, t as createRepositories, y as createDatabase } from "./repositories-pz4NeWaF.js";
2
2
  import postgres from "postgres";
3
3
  import { ContentTypeRegistry, FieldTypeRegistry, defineContentType, defineFieldType } from "@manablox/core";
4
4
  import { dirname, join, resolve } from "node:path";
@@ -0,0 +1,40 @@
1
+ CREATE TABLE "audit_entries" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "seq" bigserial NOT NULL,
4
+ "at" timestamp with time zone DEFAULT now() NOT NULL,
5
+ "space_id" uuid,
6
+ "actor_kind" text NOT NULL,
7
+ "actor_id" text,
8
+ "actor_label" text NOT NULL,
9
+ "actor_detail" jsonb,
10
+ "action" text NOT NULL,
11
+ "target_kind" text NOT NULL,
12
+ "target_id" text,
13
+ "target_label" text,
14
+ "changes" jsonb DEFAULT '[]'::jsonb NOT NULL,
15
+ "meta" jsonb,
16
+ "prev_hash" text,
17
+ "hash" text NOT NULL
18
+ );
19
+ --> statement-breakpoint
20
+ CREATE INDEX "audit_entries_seq_idx" ON "audit_entries" USING btree ("seq");--> statement-breakpoint
21
+ CREATE INDEX "audit_entries_space_at_idx" ON "audit_entries" USING btree ("space_id","at");--> statement-breakpoint
22
+ CREATE INDEX "audit_entries_target_idx" ON "audit_entries" USING btree ("target_kind","target_id");--> statement-breakpoint
23
+ CREATE INDEX "audit_entries_actor_idx" ON "audit_entries" USING btree ("actor_kind","actor_id");--> statement-breakpoint
24
+ CREATE INDEX "audit_entries_action_idx" ON "audit_entries" USING btree ("action");--> statement-breakpoint
25
+ -- The log is append-only. Postgres has no "insert-only" grant short of a second role,
26
+ -- so a trigger refuses every update and delete instead; dropping it is a deliberate,
27
+ -- visible act rather than a stray statement. Not tracked by drizzle-kit: `pnpm
28
+ -- db:generate` neither sees nor removes it.
29
+ CREATE FUNCTION audit_entries_immutable() RETURNS trigger AS $$
30
+ BEGIN
31
+ RAISE EXCEPTION 'audit_entries is append-only: % refused', TG_OP
32
+ USING ERRCODE = 'insufficient_privilege';
33
+ END;
34
+ $$ LANGUAGE plpgsql;--> statement-breakpoint
35
+ CREATE TRIGGER audit_entries_immutable
36
+ BEFORE UPDATE OR DELETE ON "audit_entries"
37
+ FOR EACH ROW EXECUTE FUNCTION audit_entries_immutable();--> statement-breakpoint
38
+ CREATE TRIGGER audit_entries_no_truncate
39
+ BEFORE TRUNCATE ON "audit_entries"
40
+ FOR EACH STATEMENT EXECUTE FUNCTION audit_entries_immutable();
@@ -0,0 +1,45 @@
1
+ CREATE TABLE "content_approvals" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "space_id" uuid NOT NULL,
4
+ "content_id" uuid NOT NULL,
5
+ "type_id" uuid NOT NULL,
6
+ "status" text DEFAULT 'pending' NOT NULL,
7
+ "requested_by" uuid,
8
+ "requested_by_label" text DEFAULT '' NOT NULL,
9
+ "request_note" text,
10
+ "content_version" integer,
11
+ "requested_at" timestamp with time zone DEFAULT now() NOT NULL,
12
+ "decided_by" uuid,
13
+ "decided_by_label" text,
14
+ "decision_note" text,
15
+ "decided_at" timestamp with time zone
16
+ );
17
+ --> statement-breakpoint
18
+ CREATE TABLE "notifications" (
19
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
20
+ "user_id" uuid NOT NULL,
21
+ "space_id" uuid,
22
+ "kind" text NOT NULL,
23
+ "title" text NOT NULL,
24
+ "body" text DEFAULT '' NOT NULL,
25
+ "url" text,
26
+ "target_kind" text,
27
+ "target_id" text,
28
+ "actor_id" text,
29
+ "actor_label" text,
30
+ "meta" jsonb,
31
+ "read_at" timestamp with time zone,
32
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
33
+ );
34
+ --> statement-breakpoint
35
+ ALTER TABLE "users" ADD COLUMN "notification_preferences" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
36
+ ALTER TABLE "content_types" ADD COLUMN "requires_approval" boolean DEFAULT false NOT NULL;--> statement-breakpoint
37
+ ALTER TABLE "content_approvals" ADD CONSTRAINT "content_approvals_space_id_spaces_id_fk" FOREIGN KEY ("space_id") REFERENCES "public"."spaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
38
+ ALTER TABLE "content_approvals" ADD CONSTRAINT "content_approvals_content_id_contents_id_fk" FOREIGN KEY ("content_id") REFERENCES "public"."contents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
39
+ ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
40
+ ALTER TABLE "notifications" ADD CONSTRAINT "notifications_space_id_spaces_id_fk" FOREIGN KEY ("space_id") REFERENCES "public"."spaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
41
+ CREATE INDEX "content_approvals_content_idx" ON "content_approvals" USING btree ("content_id","requested_at");--> statement-breakpoint
42
+ CREATE INDEX "content_approvals_space_status_idx" ON "content_approvals" USING btree ("space_id","status","requested_at");--> statement-breakpoint
43
+ CREATE INDEX "notifications_user_created_idx" ON "notifications" USING btree ("user_id","created_at");--> statement-breakpoint
44
+ CREATE INDEX "notifications_user_unread_idx" ON "notifications" USING btree ("user_id","read_at");--> statement-breakpoint
45
+ CREATE INDEX "notifications_target_idx" ON "notifications" USING btree ("target_kind","target_id");