@agent-native/core 0.157.1 → 0.157.3

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.
Files changed (24) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/server/handlers/bookings.ts +6 -1
  3. package/corpus/templates/clips/actions/request-recording-access.ts +47 -14
  4. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +125 -15
  5. package/corpus/templates/content/actions/add-database-item.ts +9 -0
  6. package/corpus/templates/content/actions/describe-content-database.ts +92 -0
  7. package/corpus/templates/content/actions/list-content-databases.ts +213 -72
  8. package/corpus/templates/content/actions/set-document-property.ts +9 -0
  9. package/corpus/templates/content/actions/submit-content-database-form.ts +9 -0
  10. package/corpus/templates/content/actions/update-document.ts +9 -0
  11. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +7 -0
  12. package/corpus/templates/content/app/components/editor/database/settings.tsx +7 -0
  13. package/corpus/templates/content/app/hooks/use-content-database.ts +91 -11
  14. package/corpus/templates/content/parity/matrix.md +1 -1
  15. package/corpus/templates/content/parity/matrix.ts +7 -1
  16. package/corpus/templates/content/server/plugins/agent-chat.ts +3 -0
  17. package/corpus/templates/content/shared/api.ts +9 -0
  18. package/dist/collab/awareness.d.ts +2 -2
  19. package/dist/notifications/routes.d.ts +3 -3
  20. package/dist/observability/routes.d.ts +5 -5
  21. package/dist/secrets/routes.d.ts +9 -9
  22. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  23. package/docs/content/cloneable-saas.mdx +24 -26
  24. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8241
34
+ - template files: 8242
@@ -483,7 +483,12 @@ async function resolveAvailabilityContext({
483
483
  draft
484
484
  ? and(
485
485
  eq(schema.bookingLinks.slug, slug),
486
- accessFilter(schema.bookingLinks, schema.bookingLinkShares),
486
+ accessFilter(
487
+ schema.bookingLinks,
488
+ schema.bookingLinkShares,
489
+ undefined,
490
+ "editor",
491
+ ),
487
492
  )
488
493
  : eq(schema.bookingLinks.slug, slug),
489
494
  )
@@ -1,3 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+
1
3
  import { defineAction } from "@agent-native/core";
2
4
  import { notify } from "@agent-native/core/notifications";
3
5
  import {
@@ -19,7 +21,7 @@ import { and, eq } from "drizzle-orm";
19
21
  import { z } from "zod";
20
22
 
21
23
  import { getDb, schema } from "../server/db/index.js";
22
- import { nanoid, normalizeOwnerEmail } from "../server/lib/recordings.js";
24
+ import { normalizeOwnerEmail } from "../server/lib/recordings.js";
23
25
  import {
24
26
  CLIPS_ACCESS_REQUEST_TOKEN_PREFIX,
25
27
  recordingSharePath,
@@ -27,6 +29,20 @@ import {
27
29
 
28
30
  export const CLIPS_ACCESS_REQUEST_EMAIL_ID = "clips.access-request";
29
31
 
32
+ function accessRequestEventId(
33
+ recordingId: string,
34
+ requesterEmail: string,
35
+ ): string {
36
+ return (
37
+ "access-request-" +
38
+ createHash("sha256")
39
+ .update(recordingId)
40
+ .update("\0")
41
+ .update(requesterEmail)
42
+ .digest("hex")
43
+ );
44
+ }
45
+
30
46
  function httpError(message: string, statusCode: number): Error {
31
47
  return Object.assign(new Error(message), { statusCode });
32
48
  }
@@ -211,19 +227,36 @@ export default defineAction({
211
227
  getRequestUserName()?.trim() ||
212
228
  displayNameForEmail(normalizedRequesterEmail);
213
229
  const requestedAt = new Date().toISOString();
214
- await db.insert(schema.recordingEvents).values({
215
- id: nanoid(),
216
- recordingId,
217
- viewerId: null,
218
- kind: "access-request",
219
- timestampMs: 0,
220
- payload: JSON.stringify({
221
- requesterEmail: normalizedRequesterEmail,
222
- requesterName,
223
- requestedAt,
224
- }),
225
- createdAt: requestedAt,
226
- });
230
+ const [insertedRequest] = await db
231
+ .insert(schema.recordingEvents)
232
+ .values({
233
+ id: accessRequestEventId(recordingId, normalizedRequesterEmail),
234
+ recordingId,
235
+ viewerId: null,
236
+ kind: "access-request",
237
+ timestampMs: 0,
238
+ payload: JSON.stringify({
239
+ requesterEmail: normalizedRequesterEmail,
240
+ requesterName,
241
+ requestedAt,
242
+ }),
243
+ createdAt: requestedAt,
244
+ })
245
+ .onConflictDoNothing()
246
+ .returning({ id: schema.recordingEvents.id });
247
+
248
+ // Historical requests use random IDs, so the read above still handles
249
+ // them. New requests use a deterministic primary key so concurrent
250
+ // callers have one database-backed winner before notifications are sent.
251
+ if (!insertedRequest) {
252
+ return {
253
+ ok: true as const,
254
+ alreadyHasAccess: false,
255
+ alreadyRequested: true,
256
+ notifiedOwner: false,
257
+ message: "Your access request is already with the clip owner.",
258
+ };
259
+ }
227
260
 
228
261
  const ownerEmail = recording.ownerEmail
229
262
  ? normalizeOwnerEmail(recording.ownerEmail)
@@ -57,6 +57,7 @@ import {
57
57
  deleteResumableSession,
58
58
  getResumableSession,
59
59
  setResumableSession,
60
+ type StoredResumableSession,
60
61
  } from "../../../../lib/resumable-session.js";
61
62
  import { abortResumableUploadSession } from "../../../../lib/resumable-upload-cleanup.js";
62
63
  import { shouldEnableStreamingUpload } from "../../../../lib/streaming-upload-mode.js";
@@ -74,6 +75,66 @@ interface CompressionMeta {
74
75
  outputMimeType?: string;
75
76
  }
76
77
 
78
+ interface PendingResumableCleanup {
79
+ recordingId: string;
80
+ generationId: string | null;
81
+ ownerGenerationId: string | null;
82
+ claimId: string | null;
83
+ session: StoredResumableSession;
84
+ }
85
+
86
+ function parsePendingResumableCleanup(
87
+ raw: Record<string, unknown> | null,
88
+ recordingId: string,
89
+ ): PendingResumableCleanup | null {
90
+ if (!raw) return null;
91
+ const session = raw.session;
92
+ const generationId = raw.generationId;
93
+ const ownerGenerationId = raw.ownerGenerationId;
94
+ const claimId = raw.claimId;
95
+ if (
96
+ raw.recordingId !== recordingId ||
97
+ !session ||
98
+ typeof session !== "object" ||
99
+ Array.isArray(session) ||
100
+ !(generationId === null || typeof generationId === "string") ||
101
+ (ownerGenerationId !== undefined &&
102
+ ownerGenerationId !== null &&
103
+ typeof ownerGenerationId !== "string") ||
104
+ (claimId !== undefined && claimId !== null && typeof claimId !== "string")
105
+ ) {
106
+ throw new Error(
107
+ `Invalid resumable cleanup state for recording ${recordingId}`,
108
+ );
109
+ }
110
+
111
+ const candidate = session as Record<string, unknown>;
112
+ if (
113
+ typeof candidate.providerId !== "string" ||
114
+ typeof candidate.sessionId !== "string" ||
115
+ !candidate.meta ||
116
+ typeof candidate.meta !== "object" ||
117
+ Array.isArray(candidate.meta) ||
118
+ typeof candidate.bytesUploaded !== "number" ||
119
+ !Number.isFinite(candidate.bytesUploaded) ||
120
+ (candidate.lastCommittedIndex !== undefined &&
121
+ typeof candidate.lastCommittedIndex !== "number")
122
+ ) {
123
+ throw new Error(
124
+ `Invalid resumable session in cleanup state for recording ${recordingId}`,
125
+ );
126
+ }
127
+
128
+ return {
129
+ recordingId,
130
+ generationId,
131
+ ownerGenerationId:
132
+ ownerGenerationId === undefined ? null : ownerGenerationId,
133
+ claimId: claimId === undefined ? null : claimId,
134
+ session: candidate as unknown as StoredResumableSession,
135
+ };
136
+ }
137
+
77
138
  function normalizeVideoMimeType(value: unknown): string | null {
78
139
  if (typeof value !== "string") return null;
79
140
  const mimeType = value.split(";")[0]?.trim().toLowerCase();
@@ -219,10 +280,25 @@ export default defineEventHandler(async (event: H3Event) => {
219
280
  const nextGenerationId = useGenerationFence ? randomUUID() : null;
220
281
  const uploadStateKey = `recording-upload-${recordingId}`;
221
282
  const uploadStateSnapshot = await readAppState(uploadStateKey);
222
- const discardedResumableSession = await getResumableSession(
283
+ const cleanupStateKey = `recording-resumable-cleanup-${recordingId}`;
284
+ const cleanupStateSnapshot = await readAppState(cleanupStateKey);
285
+ const parsedCleanup = parsePendingResumableCleanup(
286
+ cleanupStateSnapshot,
223
287
  recordingId,
224
- existingGenerationId,
225
288
  );
289
+ const pendingCleanup =
290
+ parsedCleanup &&
291
+ (parsedCleanup.ownerGenerationId === null
292
+ ? !useGenerationFence
293
+ : parsedCleanup.ownerGenerationId === existingGenerationId)
294
+ ? parsedCleanup
295
+ : null;
296
+ const discardedGenerationId = pendingCleanup
297
+ ? pendingCleanup.generationId
298
+ : existingGenerationId;
299
+ const discardedResumableSession =
300
+ pendingCleanup?.session ??
301
+ (await getResumableSession(recordingId, existingGenerationId));
226
302
  const reset = await db
227
303
  .update(schema.recordings)
228
304
  .set({
@@ -257,29 +333,63 @@ export default defineEventHandler(async (event: H3Event) => {
257
333
  };
258
334
  }
259
335
 
336
+ let cleanupClaim: PendingResumableCleanup | null = null;
260
337
  if (discardedResumableSession) {
261
- const cleaned = await abortResumableUploadSession(
262
- discardedResumableSession,
263
- { label: `reset-${recordingId}` },
338
+ // A cleanup claim belongs to the generation that won this fence. A
339
+ // losing reset must never resurrect a claim after the winner releases
340
+ // it, and a later winner must be able to transfer an unfinished claim.
341
+ const candidate: PendingResumableCleanup = {
342
+ recordingId,
343
+ generationId: discardedGenerationId,
344
+ ownerGenerationId: nextGenerationId,
345
+ claimId: randomUUID(),
346
+ session: discardedResumableSession,
347
+ };
348
+ const claimed = await compareAndSetAppState(
349
+ cleanupStateKey,
350
+ cleanupStateSnapshot,
351
+ candidate as unknown as Record<string, unknown>,
264
352
  );
265
- if (!cleaned) {
266
- setResponseStatus(event, 502);
267
- return {
268
- error:
269
- "The previous recording upload could not be cleaned up. Retry the upload restart.",
270
- };
353
+ if (claimed) cleanupClaim = candidate;
354
+ }
355
+
356
+ if (discardedResumableSession) {
357
+ if (cleanupClaim) {
358
+ const cleaned = await abortResumableUploadSession(
359
+ discardedResumableSession,
360
+ { label: `reset-${recordingId}` },
361
+ );
362
+ if (!cleaned) {
363
+ setResponseStatus(event, 502);
364
+ return {
365
+ error:
366
+ "The previous recording upload could not be cleaned up. Retry the upload restart.",
367
+ };
368
+ }
369
+ const released = await compareAndSetAppState(
370
+ cleanupStateKey,
371
+ cleanupClaim as unknown as Record<string, unknown>,
372
+ null,
373
+ );
374
+ if (!released) {
375
+ console.warn(
376
+ `[reset-chunks-${recordingId}] cleanup claim changed while releasing it`,
377
+ );
378
+ }
271
379
  }
272
380
  }
273
381
  const cleared = await deleteRecordingChunks(
274
382
  ownerEmail,
275
383
  recordingId,
276
- existingGenerationId,
384
+ discardedGenerationId,
277
385
  );
278
386
  // Clear any stale resumable session so a buffered retry does not
279
387
  // accidentally route through handleResumableChunk with stale offsets.
280
- await deleteResumableSession(recordingId, existingGenerationId).catch(
281
- () => {},
282
- );
388
+ if (!discardedResumableSession || cleanupClaim) {
389
+ await deleteResumableSession(recordingId, discardedGenerationId).catch(
390
+ () => {},
391
+ );
392
+ }
283
393
 
284
394
  let uploadMode: UploadMode = "buffered";
285
395
  let compensateStartedSession: (() => Promise<void>) | null = null;
@@ -26,6 +26,15 @@ const schema = databaseMutationEnvelopeSchema.extend({
26
26
  export default defineAction({
27
27
  description:
28
28
  "Create one row in an exact ordinary Content database using its discovered schema revision. Strictly validates every non-Blocks property, applies the side effect once per idempotency key, and returns a verified receipt with stable row identity.",
29
+ publicAgent: {
30
+ expose: true,
31
+ readOnly: false,
32
+ requiresAuth: true,
33
+ isConsequential: true,
34
+ title: "Add Content Database Item",
35
+ description:
36
+ "Delegate creation of one page item in an existing Content database.",
37
+ },
29
38
  schema,
30
39
  audit: {
31
40
  recordInputs: false,
@@ -0,0 +1,92 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { accessFilter } from "@agent-native/core/sharing";
3
+ import { and, eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+
6
+ import { getDb, schema } from "../server/db/index.js";
7
+ import { getDocumentContextPath } from "../server/lib/document-context.js";
8
+ import type {
9
+ ContentDatabaseDescriptionResponse,
10
+ ContentDatabaseUnavailableResponse,
11
+ } from "../shared/api.js";
12
+ import { resolveContentDatabaseRead } from "./_database-utils.js";
13
+ import {
14
+ listPropertiesForDatabase,
15
+ serializeDatabase,
16
+ } from "./_property-utils.js";
17
+ import listContentDatabases, {
18
+ ContentDatabaseResolutionError,
19
+ } from "./list-content-databases.js";
20
+
21
+ export default defineAction({
22
+ description:
23
+ "Describe one exact ordinary Content database, including its live metadata, views, and property schema but not its rows. Resolve the stable database or document ID with list-content-databases first.",
24
+ schema: z
25
+ .object({
26
+ databaseId: z.string().min(1).optional().describe("Exact database ID"),
27
+ documentId: z
28
+ .string()
29
+ .min(1)
30
+ .optional()
31
+ .describe("Exact database document/page ID"),
32
+ })
33
+ .refine(
34
+ (input) => Boolean(input.databaseId) !== Boolean(input.documentId),
35
+ "Provide exactly one of databaseId or documentId.",
36
+ ),
37
+ http: { method: "GET" },
38
+ readOnly: true,
39
+ publicAgent: { expose: true, readOnly: true, requiresAuth: true },
40
+ run: async ({
41
+ databaseId,
42
+ documentId,
43
+ }): Promise<
44
+ ContentDatabaseDescriptionResponse | ContentDatabaseUnavailableResponse
45
+ > => {
46
+ let selection: Awaited<ReturnType<typeof listContentDatabases.run>>;
47
+ try {
48
+ selection = await listContentDatabases.run({ databaseId, documentId });
49
+ } catch (error) {
50
+ if (!(error instanceof ContentDatabaseResolutionError)) throw error;
51
+ throw new Error("Content database not found.");
52
+ }
53
+ const selected = selection.databases[0];
54
+ if (!selected) throw new Error("Content database not found.");
55
+
56
+ const resolved = await resolveContentDatabaseRead({
57
+ databaseId: selected.databaseId,
58
+ });
59
+ if (!resolved.available) return resolved;
60
+ if (resolved.database.systemRole) {
61
+ throw new Error("Content database not found.");
62
+ }
63
+
64
+ const db = getDb();
65
+ const [databaseDocument] = await db
66
+ .select({
67
+ id: schema.documents.id,
68
+ parentId: schema.documents.parentId,
69
+ })
70
+ .from(schema.documents)
71
+ .where(
72
+ and(
73
+ eq(schema.documents.id, selected.documentId),
74
+ accessFilter(schema.documents, schema.documentShares),
75
+ ),
76
+ );
77
+ if (!databaseDocument) throw new Error("Content database not found.");
78
+
79
+ const [properties, contextPath] = await Promise.all([
80
+ listPropertiesForDatabase(resolved.database.id),
81
+ getDocumentContextPath(databaseDocument),
82
+ ]);
83
+ return {
84
+ database: serializeDatabase(
85
+ { ...resolved.database, title: selected.title },
86
+ selected.description,
87
+ ),
88
+ contextPath,
89
+ properties,
90
+ };
91
+ },
92
+ });