@nexpress/core 0.1.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.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/audit-54XLVCWD.js +14 -0
- package/dist/audit-54XLVCWD.js.map +1 -0
- package/dist/auth.d.ts +640 -0
- package/dist/auth.js +94 -0
- package/dist/auth.js.map +1 -0
- package/dist/can-YLUHRJAB.js +19 -0
- package/dist/can-YLUHRJAB.js.map +1 -0
- package/dist/chunk-2G264RCD.js +68 -0
- package/dist/chunk-2G264RCD.js.map +1 -0
- package/dist/chunk-2YDGE7YX.js +92 -0
- package/dist/chunk-2YDGE7YX.js.map +1 -0
- package/dist/chunk-473S4TER.js +538 -0
- package/dist/chunk-473S4TER.js.map +1 -0
- package/dist/chunk-4ZLMEKFX.js +18 -0
- package/dist/chunk-4ZLMEKFX.js.map +1 -0
- package/dist/chunk-55FU6WED.js +179 -0
- package/dist/chunk-55FU6WED.js.map +1 -0
- package/dist/chunk-6YI5K2TI.js +1959 -0
- package/dist/chunk-6YI5K2TI.js.map +1 -0
- package/dist/chunk-BHK3AD3Q.js +41 -0
- package/dist/chunk-BHK3AD3Q.js.map +1 -0
- package/dist/chunk-CRUQBZUF.js +39 -0
- package/dist/chunk-CRUQBZUF.js.map +1 -0
- package/dist/chunk-CTSQ7BRI.js +175 -0
- package/dist/chunk-CTSQ7BRI.js.map +1 -0
- package/dist/chunk-DK2JBJH7.js +81 -0
- package/dist/chunk-DK2JBJH7.js.map +1 -0
- package/dist/chunk-DP2PREDU.js +597 -0
- package/dist/chunk-DP2PREDU.js.map +1 -0
- package/dist/chunk-EQ2Z3KMD.js +24 -0
- package/dist/chunk-EQ2Z3KMD.js.map +1 -0
- package/dist/chunk-FZ7O6DWI.js +305 -0
- package/dist/chunk-FZ7O6DWI.js.map +1 -0
- package/dist/chunk-ISLYFQWL.js +1270 -0
- package/dist/chunk-ISLYFQWL.js.map +1 -0
- package/dist/chunk-JJL74ZPK.js +68 -0
- package/dist/chunk-JJL74ZPK.js.map +1 -0
- package/dist/chunk-JKXAPSU4.js +24 -0
- package/dist/chunk-JKXAPSU4.js.map +1 -0
- package/dist/chunk-KU5M27ZC.js +24 -0
- package/dist/chunk-KU5M27ZC.js.map +1 -0
- package/dist/chunk-LSHHRDVR.js +34 -0
- package/dist/chunk-LSHHRDVR.js.map +1 -0
- package/dist/chunk-M43PGOQY.js +715 -0
- package/dist/chunk-M43PGOQY.js.map +1 -0
- package/dist/chunk-MEJAHXIO.js +150 -0
- package/dist/chunk-MEJAHXIO.js.map +1 -0
- package/dist/chunk-NUCGHWCF.js +101 -0
- package/dist/chunk-NUCGHWCF.js.map +1 -0
- package/dist/chunk-OK5HOCQI.js +845 -0
- package/dist/chunk-OK5HOCQI.js.map +1 -0
- package/dist/chunk-OROPGO65.js +13 -0
- package/dist/chunk-OROPGO65.js.map +1 -0
- package/dist/chunk-PPAS4SZR.js +176 -0
- package/dist/chunk-PPAS4SZR.js.map +1 -0
- package/dist/chunk-PPBWRKO2.js +171 -0
- package/dist/chunk-PPBWRKO2.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/chunk-QO7LAQZH.js +321 -0
- package/dist/chunk-QO7LAQZH.js.map +1 -0
- package/dist/chunk-QVJ2HCAX.js +225 -0
- package/dist/chunk-QVJ2HCAX.js.map +1 -0
- package/dist/chunk-RIPHIRPP.js +68 -0
- package/dist/chunk-RIPHIRPP.js.map +1 -0
- package/dist/chunk-S27S42QY.js +134 -0
- package/dist/chunk-S27S42QY.js.map +1 -0
- package/dist/chunk-SBCVAC2Z.js +40 -0
- package/dist/chunk-SBCVAC2Z.js.map +1 -0
- package/dist/chunk-TFJ4MKPH.js +694 -0
- package/dist/chunk-TFJ4MKPH.js.map +1 -0
- package/dist/chunk-THX3SHYA.js +75 -0
- package/dist/chunk-THX3SHYA.js.map +1 -0
- package/dist/chunk-UGQSQO5B.js +222 -0
- package/dist/chunk-UGQSQO5B.js.map +1 -0
- package/dist/chunk-V2UNHGAP.js +26 -0
- package/dist/chunk-V2UNHGAP.js.map +1 -0
- package/dist/chunk-VGTPQXNQ.js +2790 -0
- package/dist/chunk-VGTPQXNQ.js.map +1 -0
- package/dist/chunk-VNIHXQ7W.js +194 -0
- package/dist/chunk-VNIHXQ7W.js.map +1 -0
- package/dist/chunk-WV272MPW.js +31 -0
- package/dist/chunk-WV272MPW.js.map +1 -0
- package/dist/chunk-X5KKBOUS.js +26 -0
- package/dist/chunk-X5KKBOUS.js.map +1 -0
- package/dist/chunk-XANPEOJC.js +17 -0
- package/dist/chunk-XANPEOJC.js.map +1 -0
- package/dist/chunk-XPVQIHAQ.js +83 -0
- package/dist/chunk-XPVQIHAQ.js.map +1 -0
- package/dist/chunk-ZCINJSS4.js +75 -0
- package/dist/chunk-ZCINJSS4.js.map +1 -0
- package/dist/community.d.ts +1425 -0
- package/dist/community.js +206 -0
- package/dist/community.js.map +1 -0
- package/dist/config-2GDU7PCK.js +32 -0
- package/dist/config-2GDU7PCK.js.map +1 -0
- package/dist/context-MNZ4QXPC.js +16 -0
- package/dist/context-MNZ4QXPC.js.map +1 -0
- package/dist/db-schema.d.ts +4 -0
- package/dist/db-schema.js +102 -0
- package/dist/db-schema.js.map +1 -0
- package/dist/db.d.ts +7 -0
- package/dist/db.js +117 -0
- package/dist/db.js.map +1 -0
- package/dist/digest-SY42GQSU.js +17 -0
- package/dist/digest-SY42GQSU.js.map +1 -0
- package/dist/errors-5OS3S2J3.js +22 -0
- package/dist/errors-5OS3S2J3.js.map +1 -0
- package/dist/host-OBOI4MJK.js +51 -0
- package/dist/host-OBOI4MJK.js.map +1 -0
- package/dist/i18n.d.ts +301 -0
- package/dist/i18n.js +68 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index-B6-_vr_m.d.ts +590 -0
- package/dist/index-CY55LC0u.d.ts +4722 -0
- package/dist/index-CeiTvwbp.d.ts +168 -0
- package/dist/index-XwP1ET8b.d.ts +61 -0
- package/dist/index.d.ts +2037 -0
- package/dist/index.js +2205 -0
- package/dist/index.js.map +1 -0
- package/dist/job-log-VZXWQUDK.js +24 -0
- package/dist/job-log-VZXWQUDK.js.map +1 -0
- package/dist/jobs.d.ts +4 -0
- package/dist/jobs.js +76 -0
- package/dist/jobs.js.map +1 -0
- package/dist/logger-DqGaOU_j.d.ts +29 -0
- package/dist/logger-S7REWDNE.js +16 -0
- package/dist/logger-S7REWDNE.js.map +1 -0
- package/dist/media.d.ts +5 -0
- package/dist/media.js +41 -0
- package/dist/media.js.map +1 -0
- package/dist/mentions-2IHFVSHW.js +23 -0
- package/dist/mentions-2IHFVSHW.js.map +1 -0
- package/dist/mutes-EWAE5FZR.js +21 -0
- package/dist/mutes-EWAE5FZR.js.map +1 -0
- package/dist/notification-prefs-VPJDU7I6.js +21 -0
- package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
- package/dist/observability.d.ts +156 -0
- package/dist/observability.js +32 -0
- package/dist/observability.js.map +1 -0
- package/dist/profanity-adapter-NU2JQSLX.js +12 -0
- package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
- package/dist/queue-XE5BC75T.js +14 -0
- package/dist/queue-XE5BC75T.js.map +1 -0
- package/dist/rate-limit.d.ts +99 -0
- package/dist/rate-limit.js +14 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/registry-XIXDEPVI.js +31 -0
- package/dist/registry-XIXDEPVI.js.map +1 -0
- package/dist/reputation-JRL2YQHM.js +11 -0
- package/dist/reputation-JRL2YQHM.js.map +1 -0
- package/dist/routes.d.ts +43 -0
- package/dist/routes.js +12 -0
- package/dist/routes.js.map +1 -0
- package/dist/scheduled-CIQM57HT.js +20 -0
- package/dist/scheduled-CIQM57HT.js.map +1 -0
- package/dist/seo.d.ts +410 -0
- package/dist/seo.js +44 -0
- package/dist/seo.js.map +1 -0
- package/dist/settings-FOBIESPB.js +17 -0
- package/dist/settings-FOBIESPB.js.map +1 -0
- package/dist/spam-adapter-XX3G737Z.js +12 -0
- package/dist/spam-adapter-XX3G737Z.js.map +1 -0
- package/dist/strings-VAE47B2C.js +29 -0
- package/dist/strings-VAE47B2C.js.map +1 -0
- package/dist/templates-IFVJMCJ6.js +12 -0
- package/dist/templates-IFVJMCJ6.js.map +1 -0
- package/dist/types-TlsbXS0T.d.ts +871 -0
- package/package.json +129 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/connection.ts","../src/db/generator.ts","../src/db/type-generator.ts"],"sourcesContent":["import { drizzle } from \"drizzle-orm/node-postgres\";\nimport { Pool } from \"pg\";\n\nimport { readEnvPositiveInt } from \"../config/env.js\";\nimport * as schema from \"./schema/index.js\";\n\nexport interface CreateDbConnectionConfig {\n connectionString: string;\n pool?: Pool;\n /**\n * Override Pool option defaults. Useful for tests, or for sites that need\n * to tune connection limits / timeouts. The fields explicitly set below\n * (`connectionTimeoutMillis`, `statement_timeout`) win unless callers\n * override them here.\n */\n poolOptions?: ConstructorParameters<typeof Pool>[0];\n}\n\n/**\n * Defaults chosen so a wedged Postgres (network drop, container paused,\n * lock storm) surfaces a clear error in single-digit seconds rather than\n * letting a Next.js request handler hang past the platform's request\n * deadline. Both bounds can be raised on a per-site basis via `poolOptions`\n * or globally via `NP_DB_CONNECTION_TIMEOUT_MS` / `NP_DB_STATEMENT_TIMEOUT_MS`.\n *\n * - `connectionTimeoutMillis` caps `pool.connect()` waits — kicks in when\n * the daemon TCP-accepts but never completes the Postgres handshake (the\n * Docker-Desktop-stuck failure mode).\n * - `statement_timeout` is enforced server-side by Postgres and bounds any\n * single query, including the catch-all \"select * from np_users where\n * email = $1\" path that has to be fast on the auth hot path. Sites with\n * legitimately heavy admin workloads (large audit searches, bulk\n * exports) raise this rather than dropping the bound entirely.\n */\nconst DEFAULT_CONNECTION_TIMEOUT_MS = readEnvPositiveInt(\"NP_DB_CONNECTION_TIMEOUT_MS\", 5_000);\nconst DEFAULT_STATEMENT_TIMEOUT_MS = readEnvPositiveInt(\"NP_DB_STATEMENT_TIMEOUT_MS\", 10_000);\n\nexport function createDbConnection(config: CreateDbConnectionConfig) {\n const pool =\n config.pool ??\n new Pool({\n connectionString: config.connectionString,\n connectionTimeoutMillis: DEFAULT_CONNECTION_TIMEOUT_MS,\n statement_timeout: DEFAULT_STATEMENT_TIMEOUT_MS,\n ...config.poolOptions,\n });\n\n return drizzle(pool, { schema });\n}\n","import {\n type NpArrayField,\n type NpCollectionConfig,\n type NpFieldConfig,\n type NpRelationshipField,\n} from \"../config/types.js\";\n\ninterface TableRelation {\n key: string;\n kind: \"one\" | \"many\";\n targetIdentifier: string;\n fields?: string[];\n references?: string[];\n}\n\ninterface GeneratedTable {\n identifier: string;\n tableName: string;\n columns: string[];\n indexes: string[];\n relations: TableRelation[];\n}\n\ninterface ScalarFieldResult {\n columns: string[];\n relations: TableRelation[];\n}\n\ninterface TableContext {\n collectionSlug: string;\n tableIdentifier: string;\n tableName: string;\n relationKeyPrefix: string;\n fieldPath: string[];\n parentReferenceName: string;\n parentTargetIdentifier?: string;\n}\n\nexport interface GenerateDrizzleSchemaOptions {\n /**\n * Module specifier to import the core schema tables (npUsers, npMedia) from.\n * Defaults to \"@nexpress/core\". Override when the consumer's tooling\n * (e.g. drizzle-kit's CJS resolver) can't load the core package via its\n * exports map — point at a relative path to core's source in that case.\n */\n schemaImport?: string;\n}\n\nexport function generateDrizzleSchema(\n collections: NpCollectionConfig[],\n options?: GenerateDrizzleSchemaOptions,\n): string {\n const schemaImport = options?.schemaImport ?? \"@nexpress/core\";\n const collectionTables = new Map<string, string>();\n\n for (const collection of collections) {\n collectionTables.set(collection.slug, getCollectionTableIdentifier(collection.slug));\n }\n\n const tables: GeneratedTable[] = [];\n\n for (const collection of collections) {\n const tableIdentifier = getCollectionTableIdentifier(collection.slug);\n const tableName = `np_c_${collection.slug}`;\n const columns = getBaseColumns(collection);\n const indexes = [`index(\"${tableName}_status_idx\").on(table.status)`];\n const relations: TableRelation[] = [\n {\n key: \"createdByUser\",\n kind: \"one\",\n targetIdentifier: \"npUsers\",\n fields: [\"createdBy\"],\n references: [\"id\"],\n },\n {\n key: \"updatedByUser\",\n kind: \"one\",\n targetIdentifier: \"npUsers\",\n fields: [\"updatedBy\"],\n references: [\"id\"],\n },\n ];\n\n // Phase 9.7b: collections that opt into member-write track the\n // member-author on the row itself so update/delete can perform\n // the owner check without a separate audit-log lookup. Nullable\n // because staff-authored docs in the same table leave it null;\n // the FK to np_members keeps the column safe under member\n // deletes (cascade).\n const memberAuthored = Boolean(collection.community?.memberWrite?.create);\n if (memberAuthored) {\n columns.push(\n 'memberAuthorId: uuid(\"member_author_id\").references(() => npMembers.id, { onDelete: \"set null\" })',\n );\n indexes.push(\n `index(\"${tableName}_member_author_idx\").on(table.memberAuthorId)`,\n );\n relations.push({\n key: \"memberAuthor\",\n kind: \"one\",\n targetIdentifier: \"npMembers\",\n fields: [\"memberAuthorId\"],\n references: [\"id\"],\n });\n }\n\n const scalarResult = collectScalarColumns(collection.fields, [], collectionTables);\n columns.push(...scalarResult.columns);\n relations.push(...scalarResult.relations);\n\n if (hasSlugField(collection)) {\n columns.push('slug: text(\"slug\").notNull()');\n // Phase 15.2 — multi-site scoping. Every collection\n // table gets a `site_id` column so the same slug can\n // exist in multiple sites (e.g., `/about` on the main\n // site and on `acme.example.com`). i18n collections\n // additionally key on locale, so the unique becomes\n // `(site_id, locale, slug)`; non-i18n becomes\n // `(site_id, slug)`. Single-tenant installs leave\n // every row at `site_id = 'default'`, so the\n // uniqueness constraint behaves identically to the\n // pre-15.2 `slug-only` index.\n if (collection.i18n) {\n indexes.push(\n `uniqueIndex(\"${tableName}_site_locale_slug_idx\").on(table.siteId, table.locale, table.slug)`,\n );\n } else {\n indexes.push(\n `uniqueIndex(\"${tableName}_site_slug_idx\").on(table.siteId, table.slug)`,\n );\n }\n }\n\n if (collection.i18n) {\n columns.push('locale: text(\"locale\").notNull()');\n columns.push('translationGroupId: uuid(\"translation_group_id\").notNull()');\n indexes.push(\n `index(\"${tableName}_translation_group_idx\").on(table.translationGroupId)`,\n );\n indexes.push(\n `index(\"${tableName}_locale_idx\").on(table.locale)`,\n );\n }\n\n // Phase 15.2 — multi-site scoping column. Default is\n // 'default' so existing single-tenant deployments keep\n // working without operator intervention; the migration\n // backfills existing rows. NOT NULL so writes always\n // commit a site id; pipeline reads `getCurrentSiteId()`\n // (or falls back to 'default') and stamps every row.\n // FK to `np_sites.id` is intentionally omitted from\n // codegen — adding it forces every test fixture to\n // create the default site row first; the framework\n // invariant (default site always exists post-migration)\n // gives us the same safety without the schema-level FK.\n columns.push(\n 'siteId: text(\"site_id\").default(\"default\").notNull()',\n );\n indexes.push(`index(\"${tableName}_site_idx\").on(table.siteId)`);\n\n if (hasDraftVersions(collection)) {\n columns.push(\n '_status: text(\"_status\", { enum: [\"draft\", \"published\"] }).default(\"draft\").notNull()',\n );\n }\n\n columns.push('searchVector: tsvector(\"search_vector\")');\n\n tables.push({\n identifier: tableIdentifier,\n tableName,\n columns,\n indexes,\n relations,\n });\n\n createNestedTables(\n {\n collectionSlug: collection.slug,\n tableIdentifier,\n tableName,\n relationKeyPrefix: toCamelCase(collection.slug),\n fieldPath: [],\n parentReferenceName: `${toCamelCase(collection.slug)}Id`,\n },\n collection.fields,\n tables,\n collectionTables,\n );\n }\n\n const body = tables.map(renderTable).join(\"\\n\\n\");\n const usesMembers = collections.some((c) => c.community?.memberWrite?.create);\n const schemaImports = [\"npMedia\", \"npUsers\", ...(usesMembers ? [\"npMembers\"] : [])];\n\n return [\n 'import { relations } from \"drizzle-orm\";',\n 'import { boolean, customType, doublePrecision, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from \"drizzle-orm/pg-core\";',\n `import { ${schemaImports.join(\", \")} } from \"${schemaImport}\";`,\n '',\n 'const tsvector = customType<{ data: string }>({',\n ' dataType() {',\n ' return \"tsvector\";',\n ' },',\n '});',\n '',\n body,\n ].join(\"\\n\");\n}\n\nfunction createNestedTables(\n context: TableContext,\n fields: NpFieldConfig[],\n tables: GeneratedTable[],\n collectionTables: Map<string, string>,\n): void {\n for (const field of fields) {\n if (field.type === \"group\") {\n createNestedTables(context, field.fields, tables, collectionTables);\n continue;\n }\n\n if (field.type === \"row\" || field.type === \"collapsible\") {\n createNestedTables(context, field.fields, tables, collectionTables);\n continue;\n }\n\n if (field.type === \"array\") {\n tables.push(createArrayTable(context, field, tables, collectionTables));\n continue;\n }\n\n if (field.type === \"relationship\" && field.hasMany && typeof field.relationTo === \"string\") {\n tables.push(\n createHasManyJoinTable(context, { ...field, relationTo: field.relationTo, hasMany: true }, collectionTables),\n );\n }\n }\n}\n\nfunction createArrayTable(\n context: TableContext,\n field: NpArrayField,\n tables: GeneratedTable[],\n collectionTables: Map<string, string>,\n): GeneratedTable {\n const path = [...context.fieldPath, field.name];\n const tableName = `np_c_${context.collectionSlug}__${path.join(\"__\")}`;\n const identifier = getNestedTableIdentifier(context.collectionSlug, path);\n const columns = [\n 'id: uuid(\"id\").defaultRandom().primaryKey()',\n `parentId: uuid(\"parent_id\").notNull().references(() => ${context.tableIdentifier}.id, { onDelete: \"cascade\" })`,\n 'order: integer(\"order\").default(0).notNull()',\n ];\n const relations: TableRelation[] = [\n {\n key: \"parent\",\n kind: \"one\",\n targetIdentifier: context.tableIdentifier,\n fields: [\"parentId\"],\n references: [\"id\"],\n },\n ];\n const scalarResult = collectScalarColumns(field.fields, [], collectionTables);\n columns.push(...scalarResult.columns);\n relations.push(...scalarResult.relations);\n\n const nestedContext: TableContext = {\n collectionSlug: context.collectionSlug,\n tableIdentifier: identifier,\n tableName,\n relationKeyPrefix: `${context.relationKeyPrefix}${toPascalCase(field.name)}`,\n fieldPath: path,\n parentReferenceName: \"parentId\",\n parentTargetIdentifier: context.tableIdentifier,\n };\n\n createNestedTables(nestedContext, field.fields, tables, collectionTables);\n\n return {\n identifier,\n tableName,\n columns,\n indexes: [`index(\"${tableName}_parent_idx\").on(table.parentId)`],\n relations,\n };\n}\n\nfunction createHasManyJoinTable(\n context: TableContext,\n field: NpRelationshipField & { relationTo: string; hasMany: true },\n collectionTables: Map<string, string>,\n): GeneratedTable {\n const path = [...context.fieldPath, field.name];\n const tableName = `np_c_${context.collectionSlug}__${path.join(\"__\")}`;\n const identifier = getNestedTableIdentifier(context.collectionSlug, path);\n const targetIdentifier = resolveRelationTarget(field.relationTo, collectionTables);\n const parentReferenceName = context.fieldPath.length === 0 ? `${toCamelCase(context.collectionSlug)}Id` : \"parentId\";\n\n return {\n identifier,\n tableName,\n columns: [\n 'id: uuid(\"id\").defaultRandom().primaryKey()',\n `${parentReferenceName}: uuid(\"${toSnakeCase(parentReferenceName)}\").notNull().references(() => ${context.tableIdentifier}.id, { onDelete: \"cascade\" })`,\n `targetId: uuid(\"target_id\").notNull().references(() => ${targetIdentifier}.id, { onDelete: \"cascade\" })`,\n 'order: integer(\"order\").default(0).notNull()',\n ],\n indexes: [\n `index(\"${tableName}_${toSnakeCase(parentReferenceName)}_idx\").on(table.${parentReferenceName})`,\n `uniqueIndex(\"${tableName}_parent_target_uidx\").on(table.${parentReferenceName}, table.targetId)`,\n ],\n relations: [\n {\n key: \"parent\",\n kind: \"one\",\n targetIdentifier: context.tableIdentifier,\n fields: [parentReferenceName],\n references: [\"id\"],\n },\n {\n key: \"target\",\n kind: \"one\",\n targetIdentifier,\n fields: [\"targetId\"],\n references: [\"id\"],\n },\n ],\n };\n}\n\nfunction collectScalarColumns(\n fields: NpFieldConfig[],\n prefix: string[],\n collectionTables: Map<string, string>,\n): ScalarFieldResult {\n const columns: string[] = [];\n const relations: TableRelation[] = [];\n\n for (const field of fields) {\n if (field.type === \"group\") {\n const nested = collectScalarColumns(field.fields, [...prefix, field.name], collectionTables);\n columns.push(...nested.columns);\n relations.push(...nested.relations);\n continue;\n }\n\n if (field.type === \"row\" || field.type === \"collapsible\") {\n const nested = collectScalarColumns(field.fields, prefix, collectionTables);\n columns.push(...nested.columns);\n relations.push(...nested.relations);\n continue;\n }\n\n if (field.type === \"array\") {\n continue;\n }\n\n if (field.type === \"relationship\" && field.hasMany) {\n continue;\n }\n\n const propertyName = getFlattenedFieldName(prefix, field.name);\n const columnName = toSnakeCase(propertyName);\n const column = buildScalarColumn(field, propertyName, columnName, collectionTables);\n\n if (!column) {\n continue;\n }\n\n columns.push(...column.columns);\n relations.push(...column.relations);\n }\n\n return { columns, relations };\n}\n\nfunction buildScalarColumn(\n field: Exclude<NpFieldConfig, { type: \"row\" | \"collapsible\" | \"group\" | \"array\" }> ,\n propertyName: string,\n columnName: string,\n collectionTables: Map<string, string>,\n): ScalarFieldResult | null {\n const notNull = field.required ? \".notNull()\" : \"\";\n\n switch (field.type) {\n case \"text\":\n case \"textarea\":\n case \"email\":\n case \"select\":\n case \"radio\":\n return { columns: [`${propertyName}: text(\"${columnName}\")${notNull}`], relations: [] };\n case \"number\": {\n const builder = field.integerOnly ? \"integer\" : \"doublePrecision\";\n return { columns: [`${propertyName}: ${builder}(\"${columnName}\")${notNull}`], relations: [] };\n }\n case \"richText\":\n case \"blocks\":\n case \"json\":\n return { columns: [`${propertyName}: jsonb(\"${columnName}\")${notNull}`], relations: [] };\n case \"checkbox\":\n return { columns: [`${propertyName}: boolean(\"${columnName}\")${notNull}`], relations: [] };\n case \"date\":\n return {\n columns: [`${propertyName}: timestamp(\"${columnName}\", { withTimezone: true })${notNull}`],\n relations: [],\n };\n case \"upload\": {\n return {\n columns: [`${propertyName}: uuid(\"${columnName}\").references(() => npMedia.id)${notNull}`],\n relations: [\n {\n key: propertyName,\n kind: \"one\",\n targetIdentifier: \"npMedia\",\n fields: [propertyName],\n references: [\"id\"],\n },\n ],\n };\n }\n case \"relationship\": {\n if (typeof field.relationTo !== \"string\") {\n return null;\n }\n\n const targetIdentifier = resolveRelationTarget(field.relationTo, collectionTables);\n return {\n columns: [`${propertyName}: uuid(\"${columnName}\").references(() => ${targetIdentifier}.id)${notNull}`],\n relations: [\n {\n key: propertyName,\n kind: \"one\",\n targetIdentifier,\n fields: [propertyName],\n references: [\"id\"],\n },\n ],\n };\n }\n default:\n return null;\n }\n}\n\nfunction getBaseColumns(collection: NpCollectionConfig): string[] {\n const columns = ['id: uuid(\"id\").defaultRandom().primaryKey()'];\n\n columns.push(\n 'status: text(\"status\", { enum: [\"draft\", \"scheduled\", \"published\", \"archived\", \"pending\"] }).default(\"draft\").notNull()',\n );\n\n columns.push('createdAt: timestamp(\"created_at\", { withTimezone: true }).defaultNow().notNull()');\n columns.push('updatedAt: timestamp(\"updated_at\", { withTimezone: true }).defaultNow().notNull()');\n columns.push('createdBy: uuid(\"created_by\").references(() => npUsers.id)');\n columns.push('updatedBy: uuid(\"updated_by\").references(() => npUsers.id)');\n\n // Phase 21.17 — per-doc visibility flag. Orthogonal to\n // `status` (workflow state): a row can be published-public,\n // published-private, draft-public, etc. Anonymous reads in\n // `findDocuments` auto-filter to `visibility = \"public\"` so a\n // newly-imported \"private\" row never leaks to crawlers; an\n // authenticated principal (member or staff) sees both. WP\n // imports use this to round-trip `<wp:status>private</wp:status>`\n // posts as `status=published, visibility=private` rather than\n // the old draft-coercion that lost the publish state.\n const visibility =\n 'visibility: text(\"visibility\", { enum: [\"public\", \"private\"] }).default(\"public\").notNull()';\n\n if (collection.timestamps === false) {\n return [columns[0], columns[1], columns[4], columns[5], visibility];\n }\n\n columns.push(visibility);\n return columns;\n}\n\nfunction renderTable(table: GeneratedTable): string {\n const tableSource = [\n `export const ${table.identifier} = pgTable(`,\n ` \"${table.tableName}\",`,\n \" {\",\n ...table.columns.map((column) => ` ${column},`),\n \" },\",\n ` (table) => [${table.indexes.join(\", \")}],`,\n \");\",\n ].join(\"\\n\");\n\n const relationsSource = [\n `export const ${table.identifier}Relations = relations(${table.identifier}, ({ many, one }) => ({`,\n ...table.relations.map((relation) => renderRelation(relation, table.identifier)),\n \"}));\",\n ].join(\"\\n\");\n\n return `${tableSource}\\n\\n${relationsSource}`;\n}\n\nfunction renderRelation(relation: TableRelation, ownerIdentifier: string): string {\n if (relation.kind === \"many\") {\n return ` ${relation.key}: many(${relation.targetIdentifier}),`;\n }\n\n const fields = (relation.fields ?? [])\n .map((field) => `${ownerIdentifier}.${field}`)\n .join(\", \");\n const references = (relation.references ?? [])\n .map((reference) => `${relation.targetIdentifier}.${reference}`)\n .join(\", \");\n\n return ` ${relation.key}: one(${relation.targetIdentifier}, { fields: [${fields}], references: [${references}] }),`;\n}\n\nfunction hasSlugField(collection: NpCollectionConfig): boolean {\n return collection.slugField !== undefined && collection.slugField !== false;\n}\n\nfunction hasDraftVersions(collection: NpCollectionConfig): boolean {\n return Boolean(collection.versions?.drafts);\n}\n\nfunction resolveRelationTarget(\n relationTo: string,\n collectionTables: Map<string, string>,\n): string {\n if (relationTo === \"media\") {\n return \"npMedia\";\n }\n\n if (relationTo === \"users\") {\n return \"npUsers\";\n }\n\n return collectionTables.get(relationTo) ?? getCollectionTableIdentifier(relationTo);\n}\n\nfunction getCollectionTableIdentifier(slug: string): string {\n return `${toCamelCase(slug)}Table`;\n}\n\nfunction getNestedTableIdentifier(collectionSlug: string, path: string[]): string {\n return `${toCamelCase(collectionSlug)}${path.map(toPascalCase).join(\"\")}Table`;\n}\n\nfunction getFlattenedFieldName(prefix: string[], name: string): string {\n if (prefix.length === 0) {\n return toCamelCase(name);\n }\n\n return `${prefix.map(toPascalCase).join(\"\")}${toPascalCase(name)}`.replace(/^./u, (char) => char.toLowerCase());\n}\n\nfunction toCamelCase(value: string): string {\n const parts = splitName(value);\n const [first = \"\", ...rest] = parts;\n return `${first}${rest.map(capitalize).join(\"\")}`;\n}\n\nfunction toPascalCase(value: string): string {\n return splitName(value).map(capitalize).join(\"\");\n}\n\nfunction toSnakeCase(value: string): string {\n return value\n .replace(/([a-z0-9])([A-Z])/g, \"$1_$2\")\n .replace(/[^a-zA-Z0-9]+/g, \"_\")\n .toLowerCase();\n}\n\nfunction splitName(value: string): string[] {\n return value\n .replace(/([a-z0-9])([A-Z])/g, \"$1 $2\")\n .split(/[^a-zA-Z0-9]+/)\n .map((part) => part.toLowerCase())\n .filter(Boolean);\n}\n\nfunction capitalize(value: string): string {\n return value.charAt(0).toUpperCase() + value.slice(1);\n}\n","import { type NpCollectionConfig, type NpFieldConfig } from \"../config/types.js\";\n\nexport function generateTypeScript(collections: NpCollectionConfig[]): string {\n const interfaces = collections.map((collection) => renderCollectionInterface(collection));\n return interfaces.join(\"\\n\\n\");\n}\n\ninterface HasManyDescriptor {\n /** Field name on the collection (the where clause key). */\n fieldName: string;\n /** Generated join-table import name (e.g., `postsCategoriesTable`). */\n joinTable: string;\n /** Parent FK column on the join table (e.g., `postsId`). */\n parentColumn: string;\n}\n\nfunction collectHasManyFields(collection: NpCollectionConfig): HasManyDescriptor[] {\n const collCamel = toCamelCase(collection.slug);\n const parentColumn = `${collCamel}Id`;\n const out: HasManyDescriptor[] = [];\n // Only top-level fields participate. Nested hasMany (inside row /\n // collapsible / group / array) is rare in practice and the join-\n // table naming convention doesn't carry through nesting cleanly.\n for (const field of collection.fields) {\n if (field.type === \"relationship\" && field.hasMany === true) {\n const joinTable = `${collCamel}${toPascalCase(field.name)}Table`;\n out.push({ fieldName: field.name, joinTable, parentColumn });\n }\n }\n return out;\n}\n\n/**\n * Renders a complete `documents.ts` module: imports from\n * `@nexpress/core`, per-collection `${Pascal}Document` interfaces,\n * and typed read-helper wrappers (`find${Pascal}` /\n * `get${Pascal}Document`) that bind the type generic so call sites\n * don't have to.\n *\n * Collections with hasMany relationship fields get a smarter\n * wrapper: when the caller's `where` clause names a hasMany\n * field, the wrapper queries the join table for matching parent\n * ids first, intersects across multiple hasMany filters, and\n * delegates to `findDocuments` with `id: idList`. That covers\n * the friction surfaced in #540's category-page dogfood — a\n * `where: { categories: id }` filter looked typed but failed at\n * runtime because `categories` isn't a column on the parent\n * table; this wrapper makes it work.\n *\n * The output is meant for `apps/<app>/src/db/generated/documents.ts`\n * and is a direct counterpart to `generateDrizzleSchema`'s\n * `collections.ts` (Drizzle schema). Both files come from the same\n * `nexpressConfig.collections` source so they stay in sync.\n */\nexport function generateDocumentsModule(collections: NpCollectionConfig[]): string {\n const hasManyByCollection = new Map(\n collections.map((c) => [c.slug, collectHasManyFields(c)]),\n );\n const anyHasMany = Array.from(hasManyByCollection.values()).some(\n (list) => list.length > 0,\n );\n\n const coreImports = [\n `import {`,\n ` findDocuments,`,\n ...(anyHasMany ? [` getDb,`] : []),\n ` getDocumentById,`,\n ` type NpAuthUser,`,\n ` type NpFindOptions,`,\n ` type NpFindResult,`,\n `} from \"@nexpress/core\";`,\n ].join(\"\\n\");\n\n // Drizzle + join-table imports only when at least one collection\n // has a hasMany relationship — keeps the file lean for sites\n // that don't use them.\n const drizzleImports = anyHasMany\n ? [\n `import { inArray } from \"drizzle-orm\";`,\n `import type { NodePgDatabase } from \"drizzle-orm/node-postgres\";`,\n ].join(\"\\n\")\n : \"\";\n const joinTables = Array.from(\n new Set(\n Array.from(hasManyByCollection.values())\n .flat()\n .map((d) => d.joinTable),\n ),\n ).sort();\n // Emit without the `.js` extension. The reference app's\n // `apps/web/tsconfig.json` uses `moduleResolution: \"Bundler\"`\n // (Next 16's standard); Bundler resolution + Turbopack don't\n // rewrite `.js` → `.ts` for relative imports the way tsc's\n // NodeNext resolution does, so the explicit extension breaks\n // `next build` even though `pnpm typecheck` passes (tsc handles\n // both shapes). Extension-less imports work under both\n // resolution strategies — Bundler resolves to the `.ts` file\n // directly, NodeNext does the same when the file extension\n // is omitted in TS source. See\n // https://github.com/vercel/next.js/issues for the long\n // history of `.js`-extension friction with Turbopack.\n const joinTableImports =\n joinTables.length > 0\n ? `import { ${joinTables.join(\", \")} } from \"./collections\";`\n : \"\";\n\n const imports = [coreImports, drizzleImports, joinTableImports]\n .filter(Boolean)\n .join(\"\\n\");\n\n const interfaces = collections.map((c) => renderCollectionInterface(c)).join(\"\\n\\n\");\n const helpers = collections\n .map((c) => renderReadHelpers(c, hasManyByCollection.get(c.slug) ?? []))\n .join(\"\\n\\n\");\n\n return [imports, \"\", interfaces, \"\", helpers, \"\"].join(\"\\n\");\n}\n\nfunction renderReadHelpers(\n collection: NpCollectionConfig,\n hasMany: HasManyDescriptor[],\n): string {\n const docType = `${toPascalCase(collection.slug)}Document`;\n const findFnName = `find${toPascalCase(collection.slug)}`;\n const getFnName = `get${toPascalCase(collection.slug)}Document`;\n const slug = JSON.stringify(collection.slug);\n\n const findFn =\n hasMany.length === 0\n ? renderSimpleFindFn(findFnName, slug, docType, collection.slug)\n : renderHasManyFindFn(findFnName, slug, docType, collection.slug, hasMany);\n\n return [\n findFn,\n \"\",\n `/** Typed by-id fetch for the \\`${collection.slug}\\` collection. */`,\n `export function ${getFnName}(`,\n ` id: string,`,\n ` user?: NpAuthUser,`,\n `): Promise<${docType} | null> {`,\n ` return getDocumentById<${docType}>(${slug}, id, user);`,\n `}`,\n ].join(\"\\n\");\n}\n\nfunction renderSimpleFindFn(\n findFnName: string,\n slug: string,\n docType: string,\n slugLabel: string,\n): string {\n return [\n `/** Typed listing query for the \\`${slugLabel}\\` collection. */`,\n `export function ${findFnName}(`,\n ` options: NpFindOptions<${docType}> = {},`,\n ` user?: NpAuthUser,`,\n `): Promise<NpFindResult<${docType}>> {`,\n ` return findDocuments<${docType}>(${slug}, options, user);`,\n `}`,\n ].join(\"\\n\");\n}\n\nfunction renderHasManyFindFn(\n findFnName: string,\n slug: string,\n docType: string,\n slugLabel: string,\n hasMany: HasManyDescriptor[],\n): string {\n // Build a literal array of `{ field, table, column }` for\n // runtime iteration. Keep it inline (instead of a loose const)\n // so the wrapper is one self-contained function — no helpers\n // bleed into consumer code completion.\n const descriptors = hasMany\n .map(\n (d) =>\n ` { field: ${JSON.stringify(d.fieldName)}, table: ${d.joinTable}, parent: ${d.joinTable}.${d.parentColumn} },`,\n )\n .join(\"\\n\");\n\n return [\n `/**`,\n ` * Typed listing query for the \\`${slugLabel}\\` collection.`,\n ` *`,\n ` * Pre-resolves hasMany relationship filters in the where`,\n ` * clause (${hasMany.map((d) => `\\`${d.fieldName}\\``).join(\", \")}) by`,\n ` * subquerying the join table for matching parent ids. Each`,\n ` * field accepts a single target id (most common) or an array`,\n ` * of target ids (OR semantics). Multiple hasMany filters`,\n ` * intersect — \\`where: { categories: catId, tags: tagId }\\``,\n ` * matches rows that have BOTH.`,\n ` */`,\n `export async function ${findFnName}(`,\n ` options: NpFindOptions<${docType}> = {},`,\n ` user?: NpAuthUser,`,\n `): Promise<NpFindResult<${docType}>> {`,\n ` const where = options.where ? { ...options.where } : {};`,\n ` const hasManyDescriptors = [`,\n descriptors,\n ` ];`,\n ``,\n ` const matched: string[][] = [];`,\n ` let touchedHasMany = false;`,\n ` for (const { field, table, parent } of hasManyDescriptors) {`,\n ` const value = (where as Record<string, unknown>)[field];`,\n ` if (value === undefined) continue;`,\n ` touchedHasMany = true;`,\n ` delete (where as Record<string, unknown>)[field];`,\n ` const targets = (Array.isArray(value) ? value : [value]).filter(`,\n ` (v): v is string => typeof v === \"string\" && v.length > 0,`,\n ` );`,\n ` if (targets.length === 0) {`,\n ` // Empty array short-circuits to no rows — match the`,\n ` // pipeline's array-where semantics.`,\n ` matched.push([]);`,\n ` continue;`,\n ` }`,\n ` // Cast getDb() to NodePgDatabase so the drizzle builder`,\n ` // chain (.select.from.where) carries proper return types.`,\n ` // The empty-schema generic narrows the return shape away`,\n ` // from any specific tables; the explicit \\`{ id: string }[]\\` `,\n ` // cast at the end matches the projection.`,\n ` const db = getDb() as unknown as NodePgDatabase<Record<string, never>>;`,\n ` const rows = (await db`,\n ` .select({ id: parent })`,\n ` .from(table)`,\n ` .where(inArray(table.targetId, targets))) as Array<{ id: string }>;`,\n ` matched.push(rows.map((r) => r.id));`,\n ` }`,\n ``,\n ` if (touchedHasMany) {`,\n ` // Intersect across all hasMany filters. Empty intersection`,\n ` // → return immediately; findDocuments would short-circuit`,\n ` // on the empty-array where clause anyway, but the early`,\n ` // exit saves a round-trip.`,\n ` let ids = matched[0] ?? [];`,\n ` for (let i = 1; i < matched.length; i++) {`,\n ` const set = new Set(matched[i]);`,\n ` ids = ids.filter((id) => set.has(id));`,\n ` }`,\n ``,\n ` // Honor any pre-existing user id constraint. Without this,`,\n ` // \\`where: { id: someId, categories: catId }\\` would silently`,\n ` // drop the user's id filter and return every post in that`,\n ` // category — a real foot-gun. Intersect instead.`,\n ` const existingId = (where as Record<string, unknown>).id;`,\n ` if (typeof existingId === \"string\") {`,\n ` ids = ids.includes(existingId) ? [existingId] : [];`,\n ` } else if (Array.isArray(existingId)) {`,\n ` const allowed = new Set(`,\n ` existingId.filter((v): v is string => typeof v === \"string\"),`,\n ` );`,\n ` ids = ids.filter((id) => allowed.has(id));`,\n ` }`,\n ``,\n ` if (ids.length === 0) {`,\n ` return {`,\n ` docs: [],`,\n ` totalDocs: 0,`,\n ` totalPages: 0,`,\n ` page: options.page ?? 1,`,\n ` limit: options.limit ?? 20,`,\n ` hasNextPage: false,`,\n ` hasPrevPage: false,`,\n ` };`,\n ` }`,\n ` (where as Record<string, unknown>).id = ids;`,\n ` }`,\n ``,\n ` return findDocuments<${docType}>(${slug}, { ...options, where }, user);`,\n `}`,\n ].join(\"\\n\");\n}\n\nfunction renderCollectionInterface(collection: NpCollectionConfig): string {\n const interfaceName = `${toPascalCase(collection.slug)}Document`;\n const fields = [\n 'id: string;',\n 'status: \"draft\" | \"published\" | \"archived\" | \"pending\";',\n 'createdAt: Date;',\n 'updatedAt: Date;',\n 'createdBy: string | null;',\n 'updatedBy: string | null;',\n ];\n\n if (collection.community?.memberWrite?.create) {\n fields.push('memberAuthorId: string | null;');\n }\n\n if (collection.slugField) {\n fields.push(\"slug: string;\");\n }\n\n if (collection.versions?.drafts) {\n fields.push('_status: \"draft\" | \"published\";');\n }\n\n fields.push(...renderFields(collection.fields));\n\n return [`export interface ${interfaceName} {`, ...fields.map((field) => ` ${field}`), \"}\"].join(\"\\n\");\n}\n\nfunction renderFields(fields: NpFieldConfig[], prefix: string[] = []): string[] {\n const lines: string[] = [];\n\n for (const field of fields) {\n if (field.type === \"row\" || field.type === \"collapsible\") {\n lines.push(...renderFields(field.fields, prefix));\n continue;\n }\n\n const fieldName = field.type === \"group\" ? getPropertyName(prefix, field.name) : \"\";\n\n if (field.type === \"group\") {\n const groupType = renderObjectType(field.fields);\n lines.push(`${fieldName}: ${applyNullability(groupType, field.required)};`);\n continue;\n }\n\n const propertyName = getPropertyName(prefix, field.name);\n const typeSource = getTypeSource(field);\n lines.push(`${propertyName}: ${applyNullability(typeSource, field.required)};`);\n }\n\n return lines;\n}\n\nfunction renderObjectType(fields: NpFieldConfig[]): string {\n const members = renderFields(fields).map((field) => ` ${field}`);\n return [`{`, ...members, `}`].join(\"\\n\");\n}\n\nfunction getTypeSource(field: Exclude<NpFieldConfig, { type: \"row\" | \"collapsible\" | \"group\" }>): string {\n switch (field.type) {\n case \"text\":\n case \"textarea\":\n case \"email\":\n case \"select\":\n case \"radio\":\n return \"string\";\n case \"number\":\n return \"number\";\n case \"checkbox\":\n return \"boolean\";\n case \"date\":\n return \"Date\";\n case \"upload\":\n return \"string\";\n case \"relationship\":\n return field.hasMany ? \"string[]\" : \"string\";\n case \"array\":\n return `Array<${renderObjectType(field.fields)}>`;\n case \"richText\":\n case \"blocks\":\n case \"json\":\n return \"unknown\";\n default:\n return \"unknown\";\n }\n}\n\nfunction applyNullability(typeSource: string, required?: boolean): string {\n return required ? typeSource : `${typeSource} | null`;\n}\n\nfunction getPropertyName(prefix: string[], name: string): string {\n if (prefix.length === 0) {\n return toCamelCase(name);\n }\n\n return `${prefix[0]}${prefix.slice(1).map(toPascalCase).join(\"\")}${toPascalCase(name)}`;\n}\n\nfunction toCamelCase(value: string): string {\n const parts = splitName(value);\n const [first = \"\", ...rest] = parts;\n return `${first}${rest.map(toPascalCase).join(\"\")}`;\n}\n\nfunction toPascalCase(value: string): string {\n return splitName(value)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(\"\");\n}\n\nfunction splitName(value: string): string[] {\n return value\n .replace(/([a-z0-9])([A-Z])/g, \"$1 $2\")\n .split(/[^a-zA-Z0-9]+/)\n .map((part) => part.toLowerCase())\n .filter(Boolean);\n}\n"],"mappings":"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","names":["toCamelCase","toPascalCase","splitName"]}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLogger
|
|
3
|
+
} from "./chunk-JJL74ZPK.js";
|
|
4
|
+
import {
|
|
5
|
+
getDb
|
|
6
|
+
} from "./chunk-XANPEOJC.js";
|
|
7
|
+
import {
|
|
8
|
+
npMembers
|
|
9
|
+
} from "./chunk-M43PGOQY.js";
|
|
10
|
+
|
|
11
|
+
// src/community/reputation.ts
|
|
12
|
+
import { eq, sql } from "drizzle-orm";
|
|
13
|
+
|
|
14
|
+
// src/community/reputation-adapter.ts
|
|
15
|
+
var NOOP_ADAPTER = { apply: () => 0 };
|
|
16
|
+
var currentAdapter = NOOP_ADAPTER;
|
|
17
|
+
function setReputationAdapter(adapter) {
|
|
18
|
+
if (typeof adapter?.apply !== "function") {
|
|
19
|
+
throw new Error("setReputationAdapter: adapter must implement apply()");
|
|
20
|
+
}
|
|
21
|
+
currentAdapter = adapter;
|
|
22
|
+
}
|
|
23
|
+
function getReputationAdapter() {
|
|
24
|
+
return currentAdapter;
|
|
25
|
+
}
|
|
26
|
+
function resetReputationAdapter() {
|
|
27
|
+
currentAdapter = NOOP_ADAPTER;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/community/reputation.ts
|
|
31
|
+
async function applyReputation(memberId, event) {
|
|
32
|
+
let delta;
|
|
33
|
+
try {
|
|
34
|
+
delta = await getReputationAdapter().apply(event);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
getLogger().warn("reputation adapter threw \u2014 skipping update", {
|
|
37
|
+
error: err instanceof Error ? err.message : String(err),
|
|
38
|
+
kind: event.kind,
|
|
39
|
+
memberId
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!Number.isFinite(delta)) {
|
|
44
|
+
getLogger().warn("reputation adapter returned non-finite delta", {
|
|
45
|
+
kind: event.kind,
|
|
46
|
+
memberId,
|
|
47
|
+
delta
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const truncated = Math.trunc(delta);
|
|
52
|
+
if (truncated === 0) return;
|
|
53
|
+
const db = getDb();
|
|
54
|
+
try {
|
|
55
|
+
await db.update(npMembers).set({
|
|
56
|
+
reputation: sql`${npMembers.reputation} + ${truncated}`,
|
|
57
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
58
|
+
}).where(eq(npMembers.id, memberId));
|
|
59
|
+
} catch (err) {
|
|
60
|
+
getLogger().warn("reputation update failed \u2014 skipping", {
|
|
61
|
+
error: err instanceof Error ? err.message : String(err),
|
|
62
|
+
kind: event.kind,
|
|
63
|
+
memberId,
|
|
64
|
+
delta: truncated
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export {
|
|
70
|
+
setReputationAdapter,
|
|
71
|
+
getReputationAdapter,
|
|
72
|
+
resetReputationAdapter,
|
|
73
|
+
applyReputation
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=chunk-THX3SHYA.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/community/reputation.ts","../src/community/reputation-adapter.ts"],"sourcesContent":["import { eq, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers } from \"../db/schema/community.js\";\nimport { getLogger } from \"../observability/logger.js\";\n\nimport {\n getReputationAdapter,\n type NpReputationEvent,\n} from \"./reputation-adapter.js\";\n\n/**\n * Calls the registered reputation adapter for `event`, then applies\n * the returned delta to the affected member's reputation atomically:\n *\n * UPDATE np_members SET reputation = reputation + $delta\n * WHERE id = $memberId\n *\n * Failure modes are intentionally fail-soft — a buggy adapter that\n * throws, returns a non-finite value, or hits a transient DB error\n * MUST NOT block the underlying community write (comment insert,\n * reaction toggle, etc.). The caller's transactional state is not\n * touched; we just log + skip.\n */\nexport async function applyReputation(\n memberId: string,\n event: NpReputationEvent,\n): Promise<void> {\n let delta: number;\n try {\n delta = await getReputationAdapter().apply(event);\n } catch (err) {\n getLogger().warn(\"reputation adapter threw — skipping update\", {\n error: err instanceof Error ? err.message : String(err),\n kind: event.kind,\n memberId,\n });\n return;\n }\n\n if (!Number.isFinite(delta)) {\n getLogger().warn(\"reputation adapter returned non-finite delta\", {\n kind: event.kind,\n memberId,\n delta,\n });\n return;\n }\n const truncated = Math.trunc(delta);\n if (truncated === 0) return;\n\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n try {\n await db\n .update(npMembers)\n .set({\n reputation: sql`${npMembers.reputation} + ${truncated}`,\n updatedAt: new Date(),\n })\n .where(eq(npMembers.id, memberId));\n } catch (err) {\n getLogger().warn(\"reputation update failed — skipping\", {\n error: err instanceof Error ? err.message : String(err),\n kind: event.kind,\n memberId,\n delta: truncated,\n });\n }\n}\n","/**\n * Pluggable reputation-rules hook. Sites install an adapter via\n * `setReputationAdapter()` to compute reputation deltas in response\n * to community events; the framework then atomically applies the\n * delta to `np_members.reputation`.\n *\n * Default adapter is \"no-op\" (every event returns 0) — existing\n * sites' reputation values stay at zero until they opt in.\n *\n * Adapter is single-method by design: a tagged-union `event` is the\n * only argument, the return value is a signed integer delta. This\n * keeps the API surface small while letting sites encode arbitrary\n * weighting (e.g. \"+5 for a like on a comment, −10 for a moderator\n * hide, −0 if the reactor is a brand-new account, etc.\").\n *\n * Adapters can be sync or async — the framework awaits the result.\n * Throwing aborts only the reputation update, not the underlying\n * community write (fail-soft via observability hook, same pattern\n * as the spam adapter).\n */\nexport type NpReputationEvent =\n /** A new visible comment was inserted. Flagged / hidden / deleted\n * comments do NOT emit this event. */\n | {\n kind: \"comment.created\";\n commentId: string;\n memberId: string;\n targetType: string;\n targetId: string;\n }\n /** Mod (or member with the right grant) hid a comment. Adapters\n * typically penalize the author. */\n | {\n kind: \"comment.hidden\";\n commentId: string;\n memberId: string;\n byStaff: boolean;\n reason?: string | null;\n }\n /** Mod-side hard delete (`staffDeleteComment`). The body is wiped;\n * this is harsher than `hidden` and adapters usually penalize\n * more. */\n | {\n kind: \"comment.deleted\";\n commentId: string;\n memberId: string;\n byStaff: boolean;\n }\n /** Someone reacted to the recipient's content (comment / thread /\n * reply). `recipientId` is the content author; `reactorId` is the\n * member who clicked the reaction. Self-reactions are filtered\n * before the event fires. */\n | {\n kind: \"reaction.received\";\n reactionKind: string;\n recipientId: string;\n reactorId: string;\n targetType: string;\n targetId: string;\n }\n /** Reactor undid their reaction. Symmetric to `reaction.received`;\n * adapters typically return the negative of the corresponding\n * positive delta. */\n | {\n kind: \"reaction.removed\";\n reactionKind: string;\n recipientId: string;\n reactorId: string;\n targetType: string;\n targetId: string;\n }\n /** A member created a top-level document in a collection that\n * opted into `community.memberWrite.create` (Phase 9.7a). Fires\n * after the row + revision are persisted; adapters can credit\n * reputation for thread / post creation just like comments. */\n | {\n kind: \"document.created\";\n collectionSlug: string;\n documentId: string;\n memberId: string;\n }\n /** Author deleted their own document (`memberWrite.delete`,\n * Phase 9.7b). Symmetric to `document.created`; adapters\n * typically debit the original credit so a member can't farm\n * reputation by churn-creating and deleting threads. Mod-side\n * deletes are NOT covered here — those go through the staff\n * path which doesn't emit this event. */\n | {\n kind: \"document.deleted\";\n collectionSlug: string;\n documentId: string;\n memberId: string;\n };\n\nexport interface NpReputationAdapter {\n /** Returns the integer delta to apply to the affected member's\n * reputation. Sign matters: positive credits, negative debits.\n * Non-integer values are truncated; non-finite (NaN/Infinity)\n * values are skipped. Returning 0 is the no-op path. */\n apply(event: NpReputationEvent): number | Promise<number>;\n}\n\nconst NOOP_ADAPTER: NpReputationAdapter = { apply: () => 0 };\nlet currentAdapter: NpReputationAdapter = NOOP_ADAPTER;\n\nexport function setReputationAdapter(adapter: NpReputationAdapter): void {\n if (typeof adapter?.apply !== \"function\") {\n throw new Error(\"setReputationAdapter: adapter must implement apply()\");\n }\n currentAdapter = adapter;\n}\n\nexport function getReputationAdapter(): NpReputationAdapter {\n return currentAdapter;\n}\n\n/** Reset to the no-op adapter. Tests use this between cases. */\nexport function resetReputationAdapter(): void {\n currentAdapter = NOOP_ADAPTER;\n}\n"],"mappings":";;;;;;;;;;;AAAA,SAAS,IAAI,WAAW;;;ACsGxB,IAAM,eAAoC,EAAE,OAAO,MAAM,EAAE;AAC3D,IAAI,iBAAsC;AAEnC,SAAS,qBAAqB,SAAoC;AACvE,MAAI,OAAO,SAAS,UAAU,YAAY;AACxC,UAAM,IAAI,MAAM,sDAAsD;AAAA,EACxE;AACA,mBAAiB;AACnB;AAEO,SAAS,uBAA4C;AAC1D,SAAO;AACT;AAGO,SAAS,yBAA+B;AAC7C,mBAAiB;AACnB;;;AD9FA,eAAsB,gBACpB,UACA,OACe;AACf,MAAI;AACJ,MAAI;AACF,YAAQ,MAAM,qBAAqB,EAAE,MAAM,KAAK;AAAA,EAClD,SAAS,KAAK;AACZ,cAAU,EAAE,KAAK,mDAA8C;AAAA,MAC7D,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,MACtD,MAAM,MAAM;AAAA,MACZ;AAAA,IACF,CAAC;AACD;AAAA,EACF;AAEA,MAAI,CAAC,OAAO,SAAS,KAAK,GAAG;AAC3B,cAAU,EAAE,KAAK,gDAAgD;AAAA,MAC/D,MAAM,MAAM;AAAA,MACZ;AAAA,MACA;AAAA,IACF,CAAC;AACD;AAAA,EACF;AACA,QAAM,YAAY,KAAK,MAAM,KAAK;AAClC,MAAI,cAAc,EAAG;AAErB,QAAM,KAAK,MAAM;AACjB,MAAI;AACF,UAAM,GACH,OAAO,SAAS,EAChB,IAAI;AAAA,MACH,YAAY,MAAM,UAAU,UAAU,MAAM,SAAS;AAAA,MACrD,WAAW,oBAAI,KAAK;AAAA,IACtB,CAAC,EACA,MAAM,GAAG,UAAU,IAAI,QAAQ,CAAC;AAAA,EACrC,SAAS,KAAK;AACZ,cAAU,EAAE,KAAK,4CAAuC;AAAA,MACtD,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,MACtD,MAAM,MAAM;AAAA,MACZ;AAAA,MACA,OAAO;AAAA,IACT,CAAC;AAAA,EACH;AACF;","names":[]}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NP_DEFAULT_SITE_ID
|
|
3
|
+
} from "./chunk-FZ7O6DWI.js";
|
|
4
|
+
import {
|
|
5
|
+
getCurrentSiteId,
|
|
6
|
+
requireSiteId
|
|
7
|
+
} from "./chunk-SBCVAC2Z.js";
|
|
8
|
+
import {
|
|
9
|
+
NpForbiddenError,
|
|
10
|
+
NpValidationError
|
|
11
|
+
} from "./chunk-ZCINJSS4.js";
|
|
12
|
+
import {
|
|
13
|
+
getDb
|
|
14
|
+
} from "./chunk-XANPEOJC.js";
|
|
15
|
+
import {
|
|
16
|
+
npMembers,
|
|
17
|
+
npNotifications
|
|
18
|
+
} from "./chunk-M43PGOQY.js";
|
|
19
|
+
|
|
20
|
+
// src/community/mentions.ts
|
|
21
|
+
import { inArray as inArray2 } from "drizzle-orm";
|
|
22
|
+
|
|
23
|
+
// src/community/notifications.ts
|
|
24
|
+
import { and, count, desc, eq, isNull, inArray } from "drizzle-orm";
|
|
25
|
+
async function createNotification(input) {
|
|
26
|
+
if (input.actorMemberId && input.actorMemberId !== input.memberId) {
|
|
27
|
+
const { isMuted } = await import("./mutes-EWAE5FZR.js");
|
|
28
|
+
const muted = await isMuted({
|
|
29
|
+
memberId: input.memberId,
|
|
30
|
+
targetId: input.actorMemberId
|
|
31
|
+
});
|
|
32
|
+
if (muted) return null;
|
|
33
|
+
}
|
|
34
|
+
{
|
|
35
|
+
const { isNotificationKindEnabled } = await import("./notification-prefs-VPJDU7I6.js");
|
|
36
|
+
const enabled = await isNotificationKindEnabled(input.memberId, input.kind);
|
|
37
|
+
if (!enabled) return null;
|
|
38
|
+
}
|
|
39
|
+
const db = getDb();
|
|
40
|
+
const siteId = await requireSiteId();
|
|
41
|
+
const [row] = await db.insert(npNotifications).values({
|
|
42
|
+
memberId: input.memberId,
|
|
43
|
+
kind: input.kind,
|
|
44
|
+
payload: input.payload ?? {},
|
|
45
|
+
siteId
|
|
46
|
+
}).returning();
|
|
47
|
+
if (!row) throw new Error("Notification insert returned no row");
|
|
48
|
+
return row;
|
|
49
|
+
}
|
|
50
|
+
async function listNotifications(memberId, options = {}) {
|
|
51
|
+
const db = getDb();
|
|
52
|
+
const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);
|
|
53
|
+
const offset = Math.max(options.offset ?? 0, 0);
|
|
54
|
+
const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
|
|
55
|
+
const baseWhere = and(eq(npNotifications.memberId, memberId), eq(npNotifications.siteId, siteId));
|
|
56
|
+
const where = options.unreadOnly ? and(baseWhere, isNull(npNotifications.readAt)) : baseWhere;
|
|
57
|
+
const rows = await db.select().from(npNotifications).where(where).orderBy(desc(npNotifications.createdAt)).limit(limit).offset(offset);
|
|
58
|
+
const [totalRow] = await db.select({ total: count() }).from(npNotifications).where(where);
|
|
59
|
+
const [unreadRow] = await db.select({ total: count() }).from(npNotifications).where(and(baseWhere, isNull(npNotifications.readAt)));
|
|
60
|
+
return {
|
|
61
|
+
notifications: rows,
|
|
62
|
+
totalDocs: Number(totalRow?.total ?? 0),
|
|
63
|
+
unread: Number(unreadRow?.total ?? 0)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async function unreadNotificationCount(memberId) {
|
|
67
|
+
const db = getDb();
|
|
68
|
+
const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
|
|
69
|
+
const [row] = await db.select({ total: count() }).from(npNotifications).where(
|
|
70
|
+
and(
|
|
71
|
+
eq(npNotifications.memberId, memberId),
|
|
72
|
+
eq(npNotifications.siteId, siteId),
|
|
73
|
+
isNull(npNotifications.readAt)
|
|
74
|
+
)
|
|
75
|
+
);
|
|
76
|
+
return Number(row?.total ?? 0);
|
|
77
|
+
}
|
|
78
|
+
async function markNotificationsRead(input) {
|
|
79
|
+
if (input.notificationIds.length === 0) return 0;
|
|
80
|
+
if (input.notificationIds.length > 200) {
|
|
81
|
+
throw new NpValidationError("Invalid input", [
|
|
82
|
+
{ field: "notificationIds", message: "Up to 200 ids per request" }
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
const db = getDb();
|
|
86
|
+
const siteId = await requireSiteId();
|
|
87
|
+
const updated = await db.update(npNotifications).set({ readAt: /* @__PURE__ */ new Date() }).where(
|
|
88
|
+
and(
|
|
89
|
+
eq(npNotifications.memberId, input.memberId),
|
|
90
|
+
eq(npNotifications.siteId, siteId),
|
|
91
|
+
inArray(npNotifications.id, input.notificationIds),
|
|
92
|
+
isNull(npNotifications.readAt)
|
|
93
|
+
)
|
|
94
|
+
).returning({ id: npNotifications.id });
|
|
95
|
+
return updated.length;
|
|
96
|
+
}
|
|
97
|
+
async function markAllNotificationsRead(memberId) {
|
|
98
|
+
const db = getDb();
|
|
99
|
+
const siteId = await requireSiteId();
|
|
100
|
+
const before = await unreadNotificationCount(memberId);
|
|
101
|
+
await db.update(npNotifications).set({ readAt: /* @__PURE__ */ new Date() }).where(
|
|
102
|
+
and(
|
|
103
|
+
eq(npNotifications.memberId, memberId),
|
|
104
|
+
eq(npNotifications.siteId, siteId),
|
|
105
|
+
isNull(npNotifications.readAt)
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
return before;
|
|
109
|
+
}
|
|
110
|
+
async function assertOwnsNotification(memberId, notificationId) {
|
|
111
|
+
const db = getDb();
|
|
112
|
+
const [row] = await db.select({ memberId: npNotifications.memberId }).from(npNotifications).where(eq(npNotifications.id, notificationId)).limit(1);
|
|
113
|
+
if (!row || row.memberId !== memberId) {
|
|
114
|
+
throw new NpForbiddenError("notification", "read");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/community/mentions.ts
|
|
119
|
+
var MENTION_HANDLE_RE = /^[a-z0-9][a-z0-9_-]{2,29}$/;
|
|
120
|
+
var MENTION_PATTERN = /(?<![A-Za-z0-9_])@([a-z0-9][a-z0-9_-]{2,29})(?![A-Za-z0-9_-])/g;
|
|
121
|
+
function extractMentionHandles(source) {
|
|
122
|
+
if (!source) return [];
|
|
123
|
+
const seen = /* @__PURE__ */ new Set();
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const match of source.matchAll(MENTION_PATTERN)) {
|
|
126
|
+
const handle = match[1]?.toLowerCase();
|
|
127
|
+
if (!handle || seen.has(handle)) continue;
|
|
128
|
+
seen.add(handle);
|
|
129
|
+
out.push(handle);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
function extractMentionHandlesFromRichText(content) {
|
|
134
|
+
if (!content || typeof content !== "object") return [];
|
|
135
|
+
const root = content.root;
|
|
136
|
+
if (!root || !Array.isArray(root.children)) return [];
|
|
137
|
+
const parts = [];
|
|
138
|
+
walkRichTextNodes(root.children, parts);
|
|
139
|
+
return extractMentionHandles(parts.join(""));
|
|
140
|
+
}
|
|
141
|
+
function walkRichTextNodes(nodes, parts) {
|
|
142
|
+
for (const node of nodes) {
|
|
143
|
+
if (!node || typeof node !== "object") continue;
|
|
144
|
+
const n = node;
|
|
145
|
+
if (typeof n.text === "string") parts.push(n.text);
|
|
146
|
+
if (Array.isArray(n.children)) walkRichTextNodes(n.children, parts);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function extractMentionHandlesFromDocData(data) {
|
|
150
|
+
if (!data || typeof data !== "object") return [];
|
|
151
|
+
const seen = /* @__PURE__ */ new Set();
|
|
152
|
+
for (const value of Object.values(data)) {
|
|
153
|
+
if (typeof value === "string") {
|
|
154
|
+
for (const h of extractMentionHandles(value)) seen.add(h);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (value && typeof value === "object") {
|
|
158
|
+
const root = value.root;
|
|
159
|
+
if (root && Array.isArray(root.children)) {
|
|
160
|
+
for (const h of extractMentionHandlesFromRichText(value)) seen.add(h);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return Array.from(seen);
|
|
165
|
+
}
|
|
166
|
+
async function resolveMentionedMembers(handles) {
|
|
167
|
+
if (handles.length === 0) return [];
|
|
168
|
+
const lower = Array.from(new Set(handles.map((h) => h.toLowerCase())));
|
|
169
|
+
const db = getDb();
|
|
170
|
+
const rows = await db.select({ id: npMembers.id, handle: npMembers.handle, status: npMembers.status }).from(npMembers).where(inArray2(npMembers.handle, lower));
|
|
171
|
+
return rows.filter((r) => r.status === "active").map((r) => ({ id: r.id, handle: r.handle }));
|
|
172
|
+
}
|
|
173
|
+
async function fanOutMentionNotifications(input) {
|
|
174
|
+
const handles = /* @__PURE__ */ new Set();
|
|
175
|
+
if (input.source) {
|
|
176
|
+
for (const h of extractMentionHandles(input.source)) handles.add(h);
|
|
177
|
+
}
|
|
178
|
+
if (input.content !== void 0) {
|
|
179
|
+
for (const h of extractMentionHandlesFromRichText(input.content)) handles.add(h);
|
|
180
|
+
}
|
|
181
|
+
if (input.data) {
|
|
182
|
+
for (const h of extractMentionHandlesFromDocData(input.data)) handles.add(h);
|
|
183
|
+
}
|
|
184
|
+
if (input.previousHandles) {
|
|
185
|
+
for (const prev of input.previousHandles) handles.delete(prev);
|
|
186
|
+
}
|
|
187
|
+
if (handles.size === 0) return 0;
|
|
188
|
+
const targets = await resolveMentionedMembers(Array.from(handles));
|
|
189
|
+
let fired = 0;
|
|
190
|
+
for (const t of targets) {
|
|
191
|
+
if (t.id === input.actorMemberId) continue;
|
|
192
|
+
if (input.exclude?.has(t.id)) continue;
|
|
193
|
+
const row = await createNotification({
|
|
194
|
+
memberId: t.id,
|
|
195
|
+
kind: input.kind,
|
|
196
|
+
actorMemberId: input.actorMemberId,
|
|
197
|
+
payload: {
|
|
198
|
+
...input.payload ?? {},
|
|
199
|
+
mentionedMemberId: t.id,
|
|
200
|
+
mentionedHandle: t.handle
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
if (row) fired += 1;
|
|
204
|
+
}
|
|
205
|
+
return fired;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export {
|
|
209
|
+
createNotification,
|
|
210
|
+
listNotifications,
|
|
211
|
+
unreadNotificationCount,
|
|
212
|
+
markNotificationsRead,
|
|
213
|
+
markAllNotificationsRead,
|
|
214
|
+
assertOwnsNotification,
|
|
215
|
+
MENTION_HANDLE_RE,
|
|
216
|
+
extractMentionHandles,
|
|
217
|
+
extractMentionHandlesFromRichText,
|
|
218
|
+
extractMentionHandlesFromDocData,
|
|
219
|
+
resolveMentionedMembers,
|
|
220
|
+
fanOutMentionNotifications
|
|
221
|
+
};
|
|
222
|
+
//# sourceMappingURL=chunk-UGQSQO5B.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/community/mentions.ts","../src/community/notifications.ts"],"sourcesContent":["import { inArray } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers } from \"../db/schema/community.js\";\n\nimport { createNotification } from \"./notifications.js\";\n\n/**\n * Phase 16.2 — @mention extraction + notification fan-out.\n *\n * The mention vocabulary mirrors the handle constraint enforced\n * during registration (`/^[a-z0-9][a-z0-9_-]{2,29}$/`). The matcher\n * uses a negative lookbehind so `email@host.com` doesn't trigger a\n * mention, plus a negative lookahead so `@alice-` (handle followed\n * by a hyphen that's not part of the handle) is rejected — handles\n * end at non-handle characters, never mid-symbol.\n *\n * Fan-out semantics:\n * - Self-mentions are skipped (the author already knows).\n * - Caller-supplied `exclude` set lets the comment write path\n * skip the parent author so they don't get both `comment.reply`\n * AND `comment.mention`.\n * - Caller-supplied `previousHandles` lets the edit path only\n * notify newly-added mentions (otherwise toggling a single\n * other word in a comment would re-notify everyone).\n * - Inactive / banned / deleted members are filtered out at\n * resolve time.\n * - Mute is enforced inside `createNotification` (the\n * recipient's mute list drops actor-keyed notifications).\n */\n\n/** Source-of-truth handle pattern, kept in sync with `apps/web` register routes. */\nexport const MENTION_HANDLE_RE = /^[a-z0-9][a-z0-9_-]{2,29}$/;\n\nconst MENTION_PATTERN = /(?<![A-Za-z0-9_])@([a-z0-9][a-z0-9_-]{2,29})(?![A-Za-z0-9_-])/g;\n\nexport interface NpMentionTarget {\n id: string;\n handle: string;\n}\n\n/**\n * Extract unique mention handles from plain text or markdown source.\n * Order is preserved (first appearance wins) so a UI that wants to\n * display \"you mentioned @alice and @bob\" gets the same order as\n * the body text.\n */\nexport function extractMentionHandles(source: string): string[] {\n if (!source) return [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const match of source.matchAll(MENTION_PATTERN)) {\n const handle = match[1]?.toLowerCase();\n if (!handle || seen.has(handle)) continue;\n seen.add(handle);\n out.push(handle);\n }\n return out;\n}\n\n/**\n * Walk a Lexical-shaped rich-text payload, concatenate its text\n * nodes, and run the mention extractor over the joined result.\n * Mirrors the search-index walker (`collections/search.ts`) so a\n * mention split across two adjacent text spans (e.g. `@` and\n * `alice` in different runs because of formatting toggles) still\n * resolves correctly — text nodes are joined without separators.\n */\nexport function extractMentionHandlesFromRichText(content: unknown): string[] {\n if (!content || typeof content !== \"object\") return [];\n const root = (content as { root?: { children?: unknown } }).root;\n if (!root || !Array.isArray(root.children)) return [];\n const parts: string[] = [];\n walkRichTextNodes(root.children, parts);\n return extractMentionHandles(parts.join(\"\"));\n}\n\nfunction walkRichTextNodes(nodes: unknown[], parts: string[]): void {\n for (const node of nodes) {\n if (!node || typeof node !== \"object\") continue;\n const n = node as Record<string, unknown>;\n if (typeof n.text === \"string\") parts.push(n.text);\n if (Array.isArray(n.children)) walkRichTextNodes(n.children, parts);\n }\n}\n\n/**\n * Scan a collection-document data payload (the same shape passed\n * to `createMemberDocument` / `updateMemberDocument`) and pull\n * out every mention handle it contains. String values are scanned\n * with the markdown extractor; object values shaped like Lexical\n * rich text (`{ root: { children: [...] } }`) are walked. Other\n * values are ignored.\n *\n * Field names are not assumed: any string or rich-text field\n * contributes. The mention pattern is anchored to `@<handle>`\n * with handle-shape constraints, so unrelated string fields\n * (`category: \"news\"`) won't trigger false positives.\n */\nexport function extractMentionHandlesFromDocData(data: Record<string, unknown>): string[] {\n if (!data || typeof data !== \"object\") return [];\n const seen = new Set<string>();\n for (const value of Object.values(data)) {\n if (typeof value === \"string\") {\n for (const h of extractMentionHandles(value)) seen.add(h);\n continue;\n }\n if (value && typeof value === \"object\") {\n const root = (value as { root?: { children?: unknown } }).root;\n if (root && Array.isArray(root.children)) {\n for (const h of extractMentionHandlesFromRichText(value)) seen.add(h);\n }\n }\n }\n return Array.from(seen);\n}\n\n/**\n * Resolve handles to active member ids. Inactive / banned /\n * deleted members are filtered out so a mention of an account\n * the site no longer wants to notify is silently dropped (rather\n * than raising an error to the writer — the writer can't tell the\n * difference between \"typo\" and \"account closed\", and either way\n * the right behaviour is \"no notification\").\n *\n * Lookups are case-insensitive on the handle (the storage column\n * stores the canonical lowercased form).\n */\nexport async function resolveMentionedMembers(handles: string[]): Promise<NpMentionTarget[]> {\n if (handles.length === 0) return [];\n const lower = Array.from(new Set(handles.map((h) => h.toLowerCase())));\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const rows = (await db\n .select({ id: npMembers.id, handle: npMembers.handle, status: npMembers.status })\n .from(npMembers)\n .where(inArray(npMembers.handle, lower))) as Array<{\n id: string;\n handle: string;\n status: string;\n }>;\n return rows.filter((r) => r.status === \"active\").map((r) => ({ id: r.id, handle: r.handle }));\n}\n\nexport interface FanOutMentionsInput {\n /** The author whose write triggered the fan-out. Self-mentions are skipped. */\n actorMemberId: string;\n /** Notification `kind` (e.g. `\"comment.mention\"`, `\"discussion.mention\"`). */\n kind: string;\n /**\n * Plain text or markdown to scan. Either `source` or `content`\n * (or both) must be provided; if both are set the handles are\n * unioned.\n */\n source?: string;\n /** Lexical-shaped rich-text JSON to scan. */\n content?: unknown;\n /**\n * Collection-document data payload to scan. All string +\n * rich-text fields contribute. Useful for the\n * `createMemberDocument` / `updateMemberDocument` paths.\n */\n data?: Record<string, unknown>;\n /**\n * Recipients that already received a notification for this same\n * event (e.g. the parent author got `comment.reply`). They are\n * skipped to avoid the \"two pings for one comment\" pattern.\n */\n exclude?: ReadonlySet<string>;\n /** Merged into the notification payload. `mentionedMemberId` is added automatically. */\n payload?: Record<string, unknown>;\n /**\n * Edit path: handles that were present in the prior revision\n * are skipped so toggling unrelated words doesn't re-notify\n * everyone already mentioned.\n */\n previousHandles?: ReadonlySet<string>;\n}\n\n/**\n * Fan-out mention notifications. Returns the number of\n * notifications actually inserted (mute / inactive / self / dedup\n * exclusions all reduce the count).\n */\nexport async function fanOutMentionNotifications(input: FanOutMentionsInput): Promise<number> {\n const handles = new Set<string>();\n if (input.source) {\n for (const h of extractMentionHandles(input.source)) handles.add(h);\n }\n if (input.content !== undefined) {\n for (const h of extractMentionHandlesFromRichText(input.content)) handles.add(h);\n }\n if (input.data) {\n for (const h of extractMentionHandlesFromDocData(input.data)) handles.add(h);\n }\n if (input.previousHandles) {\n for (const prev of input.previousHandles) handles.delete(prev);\n }\n if (handles.size === 0) return 0;\n\n const targets = await resolveMentionedMembers(Array.from(handles));\n let fired = 0;\n for (const t of targets) {\n if (t.id === input.actorMemberId) continue;\n if (input.exclude?.has(t.id)) continue;\n const row = await createNotification({\n memberId: t.id,\n kind: input.kind,\n actorMemberId: input.actorMemberId,\n payload: {\n ...(input.payload ?? {}),\n mentionedMemberId: t.id,\n mentionedHandle: t.handle,\n },\n });\n if (row) fired += 1;\n }\n return fired;\n}\n","import { and, count, desc, eq, isNull, inArray } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npNotifications } from \"../db/schema/community.js\";\nimport { NpForbiddenError, NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId, requireSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\n/**\n * Per-member notification inbox. v1 is synchronous: every event that\n * generates a notification writes a row immediately. The inbox is\n * in-app only — email fan-out and per-member frequency preferences\n * are out of scope for the shipped roadmap.\n *\n * `kind` is a free-form string. The current vocabulary:\n * - `comment.reply` — your comment got a reply\n * - `reaction.received` — someone reacted to your content\n * - `follow.received` — someone followed you\n * Plugins can write their own kinds; the recipient UI fans them out\n * to whichever rendering it knows.\n */\n\nexport interface NpNotificationRow {\n id: string;\n memberId: string;\n kind: string;\n payload: Record<string, unknown>;\n readAt: Date | null;\n createdAt: Date;\n}\n\nexport interface CreateNotificationInput {\n /** The recipient — whose inbox this lands in. */\n memberId: string;\n kind: string;\n payload?: Record<string, unknown>;\n /**\n * Phase 16.1 — the member whose action triggered the\n * notification (e.g. the comment author, the reactor, the\n * follower). When set, the recipient's mute list is\n * consulted: if the recipient has muted the actor, the\n * notification is silently dropped. Returns `null` from\n * the call site.\n *\n * Optional because some kinds are actor-less (system\n * notices, scheduled reminders).\n */\n actorMemberId?: string | null;\n}\n\nexport async function createNotification(\n input: CreateNotificationInput,\n): Promise<NpNotificationRow | null> {\n // Mute check — defer the import to avoid a notifications →\n // mutes circular at module load. Mutes module imports\n // nothing back from here, but TypeScript sometimes flags\n // the cycle anyway depending on resolver order.\n if (input.actorMemberId && input.actorMemberId !== input.memberId) {\n const { isMuted } = await import(\"./mutes.js\");\n const muted = await isMuted({\n memberId: input.memberId,\n targetId: input.actorMemberId,\n });\n if (muted) return null;\n }\n\n // Phase 16.3 — recipient-controlled kind toggle. Fails open\n // on read error (transient DB blip shouldn't silently swallow\n // notifications). Deferred import for the same reason as\n // mutes.\n {\n const { isNotificationKindEnabled } = await import(\"./notification-prefs.js\");\n const enabled = await isNotificationKindEnabled(input.memberId, input.kind);\n if (!enabled) return null;\n }\n\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Phase 18 — site comes from the request resolver. The\n // notification belongs to the tenant where the actor's\n // action happened (a reaction on tenant A → notification\n // shows up in the recipient's tenant-A inbox).\n // #272 — write: must NOT silently fall through; an actor on\n // tenant A would otherwise create a notification on the\n // default tenant.\n const siteId = await requireSiteId();\n const [row] = (await db\n .insert(npNotifications)\n .values({\n memberId: input.memberId,\n kind: input.kind,\n payload: input.payload ?? {},\n siteId,\n })\n .returning()) as NpNotificationRow[];\n if (!row) throw new Error(\"Notification insert returned no row\");\n return row;\n}\n\nexport interface ListNotificationsOptions {\n /** Default 50, max 200. */\n limit?: number;\n /** Default 0. */\n offset?: number;\n /** When true, returns only unread. */\n unreadOnly?: boolean;\n}\n\nexport interface NpNotificationListResult {\n notifications: NpNotificationRow[];\n totalDocs: number;\n unread: number;\n}\n\nexport async function listNotifications(\n memberId: string,\n options: ListNotificationsOptions = {},\n): Promise<NpNotificationListResult> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);\n const offset = Math.max(options.offset ?? 0, 0);\n\n // Phase 18 — inbox is per-site. A member who's active on\n // multiple tenants sees a separate notification list on each.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const baseWhere = and(eq(npNotifications.memberId, memberId), eq(npNotifications.siteId, siteId));\n const where = options.unreadOnly ? and(baseWhere, isNull(npNotifications.readAt)) : baseWhere;\n\n const rows = (await db\n .select()\n .from(npNotifications)\n .where(where)\n .orderBy(desc(npNotifications.createdAt))\n .limit(limit)\n .offset(offset)) as NpNotificationRow[];\n\n const [totalRow] = (await db\n .select({ total: count() })\n .from(npNotifications)\n .where(where)) as Array<{ total: number | string }>;\n\n const [unreadRow] = (await db\n .select({ total: count() })\n .from(npNotifications)\n .where(and(baseWhere, isNull(npNotifications.readAt)))) as Array<{\n total: number | string;\n }>;\n\n return {\n notifications: rows,\n totalDocs: Number(totalRow?.total ?? 0),\n unread: Number(unreadRow?.total ?? 0),\n };\n}\n\nexport async function unreadNotificationCount(memberId: string): Promise<number> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Phase 18 — count only notifications on the current site.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const [row] = (await db\n .select({ total: count() })\n .from(npNotifications)\n .where(\n and(\n eq(npNotifications.memberId, memberId),\n eq(npNotifications.siteId, siteId),\n isNull(npNotifications.readAt),\n ),\n )) as Array<{ total: number | string }>;\n return Number(row?.total ?? 0);\n}\n\nexport interface MarkReadInput {\n memberId: string;\n notificationIds: string[];\n}\n\nexport async function markNotificationsRead(input: MarkReadInput): Promise<number> {\n if (input.notificationIds.length === 0) return 0;\n if (input.notificationIds.length > 200) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"notificationIds\", message: \"Up to 200 ids per request\" },\n ]);\n }\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Issue #219 — scope the update to the current site so a member\n // active on multiple tenants can't mark IDs read across tenants\n // by passing a site-A request that names site-B notification ids.\n // The caller's existing `memberId` predicate covered ownership\n // but not tenant; without this, unread counts on the other site\n // would silently drop. Using `returning({ id })` also gives us\n // an exact count instead of a follow-up SELECT — replaces the\n // pre-existing best-effort COUNT round trip.\n // #272 — write: must NOT silently fall through.\n const siteId = await requireSiteId();\n const updated = (await db\n .update(npNotifications)\n .set({ readAt: new Date() })\n .where(\n and(\n eq(npNotifications.memberId, input.memberId),\n eq(npNotifications.siteId, siteId),\n inArray(npNotifications.id, input.notificationIds),\n isNull(npNotifications.readAt),\n ),\n )\n .returning({ id: npNotifications.id })) as Array<{ id: string }>;\n return updated.length;\n}\n\nexport async function markAllNotificationsRead(memberId: string): Promise<number> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Phase 18 — \"mark all read\" only marks the current site's\n // inbox so a member doesn't accidentally clear another\n // tenant's unread count when toggling on this one.\n // #272 — write: must NOT silently fall through.\n const siteId = await requireSiteId();\n const before = await unreadNotificationCount(memberId);\n await db\n .update(npNotifications)\n .set({ readAt: new Date() })\n .where(\n and(\n eq(npNotifications.memberId, memberId),\n eq(npNotifications.siteId, siteId),\n isNull(npNotifications.readAt),\n ),\n );\n return before;\n}\n\n/**\n * Internal sanity check used by the API: throws when one principal\n * tries to read another member's notification. Centralised here\n * because every per-id route gets the same rule.\n */\nexport async function assertOwnsNotification(\n memberId: string,\n notificationId: string,\n): Promise<void> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const [row] = (await db\n .select({ memberId: npNotifications.memberId })\n .from(npNotifications)\n .where(eq(npNotifications.id, notificationId))\n .limit(1)) as Array<{ memberId: string }>;\n if (!row || row.memberId !== memberId) {\n throw new NpForbiddenError(\"notification\", \"read\");\n }\n}\n"],"mappings":"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","names":["inArray","inArray"]}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/jobs/queue.ts
|
|
2
|
+
var jobQueue = null;
|
|
3
|
+
function setJobQueue(queue) {
|
|
4
|
+
jobQueue = queue;
|
|
5
|
+
}
|
|
6
|
+
function getJobQueue() {
|
|
7
|
+
if (!jobQueue) {
|
|
8
|
+
throw new Error("Job queue not initialized. Call setJobQueue() first.");
|
|
9
|
+
}
|
|
10
|
+
return jobQueue;
|
|
11
|
+
}
|
|
12
|
+
function getOptionalJobQueue() {
|
|
13
|
+
return jobQueue;
|
|
14
|
+
}
|
|
15
|
+
async function enqueueJob(type, data) {
|
|
16
|
+
if (!jobQueue) return "";
|
|
17
|
+
return jobQueue.enqueue(type, data);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
setJobQueue,
|
|
22
|
+
getJobQueue,
|
|
23
|
+
getOptionalJobQueue,
|
|
24
|
+
enqueueJob
|
|
25
|
+
};
|
|
26
|
+
//# sourceMappingURL=chunk-V2UNHGAP.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/jobs/queue.ts"],"sourcesContent":["import { type NpJobType } from \"../config/types.js\";\n\n/**\n * Phase 13 — admin-side job introspection. pg-boss tracks jobs\n * across two tables (`pgboss.job` for active/scheduled,\n * `pgboss.archive` for completed/failed); the framework\n * surfaces a unified shape so the admin UI doesn't have to\n * know the storage split.\n */\nexport type NpJobState =\n | \"created\" // queued, not yet started\n | \"active\" // worker is processing\n | \"completed\" // succeeded\n | \"failed\" // hit max retries\n | \"retry\" // failed once, scheduled to retry\n | \"cancelled\" // explicitly cancelled\n | \"expired\"; // exceeded keepUntil\n\nexport interface NpJobSummary {\n id: string;\n /** pg-boss queue name (after `:` → `.` translation). */\n name: string;\n state: NpJobState;\n data: unknown;\n /** Number of retries pg-boss has attempted so far. */\n retryCount?: number;\n /** Last failure message, if any. */\n output?: string | null;\n createdOn: string;\n startedOn?: string | null;\n completedOn?: string | null;\n /**\n * Phase 20.4 — which pg-boss table the row was read from.\n * `\"live\"` = pgboss.job (still pending / active / retry),\n * `\"archive\"` = pgboss.archive (rolled out by pg-boss after\n * `keepUntil`). The admin Jobs view uses this to split the\n * Failed tab into \"live failures\" (still actionable via\n * `/api/admin/jobs/{id}/retry`) vs \"archived\" (kept for\n * forensics; retry would re-create the row in `job`).\n */\n source?: \"live\" | \"archive\";\n}\n\nexport interface NpJobListOptions {\n /** Filter to one queue name (e.g. `\"media.processImage\"`). */\n name?: string;\n /** Filter to one state. Defaults to all. */\n state?: NpJobState;\n /** Page size. Default 50, capped at 200. */\n limit?: number;\n /** Skip count for pagination. */\n offset?: number;\n /**\n * Phase 13.2 — only include jobs whose `created_on` is at or\n * after this timestamp. Common operational query: \"jobs from\n * the last 24 hours\" without paging through history.\n */\n since?: Date;\n /**\n * Phase 20.4 — partition the result by pg-boss table:\n * - `\"live\"` — pending / active / retry rows still in\n * `pgboss.job`. Retryable.\n * - `\"archive\"` — rolled rows in `pgboss.archive`. Read-only\n * (pg-boss won't pick them up; retry routes refuse to\n * touch archive rows).\n * Default (undefined) keeps the historical UNION behavior.\n */\n source?: \"live\" | \"archive\";\n}\n\nexport interface NpJobListResult {\n jobs: NpJobSummary[];\n total: number;\n}\n\n/**\n * Phase 23.5 — counts per terminal-and-transient state across the\n * union of `pgboss.job` and `pgboss.archive`. Drives the stuck-job\n * widget in `/admin/jobs` and is the building block plugin authors\n * use to roll their own monitoring without taking a hard dep on\n * pg-boss schema knowledge.\n *\n * Every state key is always present (defaulting to 0) so the\n * caller can index without optional chaining and the UI can render\n * a stable row order.\n */\nexport interface NpJobStateCounts {\n created: number;\n active: number;\n completed: number;\n failed: number;\n retry: number;\n cancelled: number;\n expired: number;\n}\n\nexport interface NpJobCountOptions {\n /**\n * Time-bounded query: include only jobs whose `created_on` is at\n * or after this timestamp. Useful for \"failures in the last 24\n * hours\" without paging through history.\n */\n since?: Date;\n}\n\n/**\n * Phase 13.2 — registered cron schedule (one row per\n * `boss.schedule()` call). Surfaces in the admin so\n * operators can confirm `system:revisionPrune` and friends\n * are actually registered, not just declared in code.\n */\nexport interface NpScheduleSummary {\n /** pg-boss queue name (after `:` → `.` translation). */\n name: string;\n /**\n * Issue #217 — the second half of `pgboss.schedule`'s primary\n * key. Empty string for single-cadence schedules; `\"daily\"` /\n * `\"weekly\"` (etc.) for jobs that need multiple cadences under\n * one queue name. The admin UI uses `(name, key)` as a stable\n * React key so duplicate-name rows render cleanly.\n */\n key: string;\n /** Cron expression as registered. */\n cron: string;\n /** Timezone the cron runs in (defaults to UTC in pg-boss). */\n timezone: string | null;\n /** Default payload used when the cron fires. */\n data: unknown;\n createdOn: string;\n updatedOn?: string | null;\n}\n\nexport interface NpJobQueue {\n enqueue(type: NpJobType, data: unknown): Promise<string>;\n start(): Promise<void>;\n stop(): Promise<void>;\n /**\n * Phase 13 — admin introspection. Optional on the interface\n * so test stubs / mock queues don't have to implement them;\n * the admin endpoint returns 501 when the active queue\n * doesn't support introspection.\n */\n listJobs?(options: NpJobListOptions): Promise<NpJobListResult>;\n /** Re-enqueue a failed/cancelled job's payload as a new job. Returns the new job id. */\n retryJob?(id: string): Promise<string>;\n /** Cancel a pending job (no-op for already-running / completed jobs). */\n cancelJob?(id: string): Promise<void>;\n /**\n * Phase 13.2 — list every cron schedule registered with the\n * queue. Surfaces in the admin so operators can confirm\n * recurring jobs are actually registered, not just declared\n * in code.\n */\n listSchedules?(): Promise<NpScheduleSummary[]>;\n /**\n * Phase 20.2 — stop the worker from claiming new jobs without\n * tearing down the queue. In-flight jobs run to completion;\n * the producer keeps enqueueing. Optional on the interface\n * because non-pg-boss test stubs don't implement it.\n */\n pauseProcessing?(): Promise<void>;\n /** Phase 20.2 — undo `pauseProcessing()`. Idempotent. */\n resumeProcessing?(): Promise<void>;\n /** Phase 20.2 — `true` when this adapter is currently paused. */\n isProcessingPaused?(): boolean;\n /**\n * Phase 22.4 — readiness probe. Issues a cheap round-trip against\n * the queue backing store and returns `true` when the connection\n * is alive AND the queue's schema is installed. Adapters that\n * can't tell return `true` (a missing answer is not a failure\n * signal). Errors are caught and reported as `false` — the probe\n * caller never sees an exception.\n */\n isHealthy?(): Promise<boolean>;\n /**\n * Phase 23.5 — return job counts grouped by state across both\n * pg-boss tables. Optional on the interface so test stubs that\n * don't model state need not implement it; the admin endpoint\n * omits the stuck-job widget when missing.\n */\n countByState?(options?: NpJobCountOptions): Promise<NpJobStateCounts>;\n /**\n * Phase 4.2 — per-plugin schedule observability. Returns one row per\n * `(pluginId, taskId)` aggregated over the plugin's history in\n * `pgboss.job` + `pgboss.archive`: last completion, last failure, and\n * counts split by state over the last `windowDays` (default 7). The\n * registry-side cron / description is overlaid on top by the caller.\n *\n * Optional so test stubs that don't model job history can omit it; the\n * admin surface degrades to \"registered schedules only\" without it.\n */\n getPluginScheduleStats?(\n pluginId: string,\n options?: { windowDays?: number },\n ): Promise<NpPluginScheduleStats[]>;\n /**\n * Issue #461 — bring the queue's `pgboss.schedule` rows in sync with\n * what `getRegisteredPluginSchedules()` reports today. Bootstrap\n * registers schedules once at worker startup; without this method,\n * `reloadPlugins()` could only update the in-memory registry, leaving\n * pg-boss firing the *old* set of crons until the worker restarted.\n *\n * Behavior:\n * - schedule entry in registry but missing from pg-boss → added\n * - schedule entry in pg-boss but missing from registry → removed\n * - same name + different cron expression → re-added (unschedule\n * then schedule, since pg-boss has no in-place cron update)\n *\n * `boss.work()` registration is NOT touched: in production deploys\n * the worker lives in a separate process from the admin web server,\n * so the web process can't install / drop work loops on its boss\n * instance for the worker process to pick up. Operators see their\n * cron rows updated immediately; jobs that fire for newly-added\n * schedules will still need a worker restart to be processed.\n * Documented in the admin reload toast.\n *\n * Optional on the interface so test stubs / non-pg-boss adapters\n * skip cleanly.\n */\n reconcilePluginSchedules?(): Promise<NpReconcileSchedulesResult>;\n}\n\nexport interface NpReconcileSchedulesResult {\n /** New schedule rows written to `pgboss.schedule`. */\n added: number;\n /** Existing rows whose cron expression changed (unschedule → reschedule). */\n updated: number;\n /** Stale rows removed (plugin was uninstalled / disabled / renamed). */\n removed: number;\n /**\n * `true` when this process holds the worker `boss.work()` registrations\n * for the affected schedules. When false, operators should restart the\n * worker to pick up newly-added schedules. Adapters that can't tell\n * (in-memory test queue, future adapters) return `null`.\n */\n workerOwnsRegistrations: boolean | null;\n}\n\nexport interface NpPluginScheduleStats {\n taskId: string;\n /** Most recent run, regardless of state. ISO timestamp or null. */\n lastRunAt: string | null;\n /** Most recent successful run. ISO timestamp or null. */\n lastSuccessAt: string | null;\n /** Most recent failed run. ISO timestamp or null. */\n lastFailureAt: string | null;\n /** Count of successful runs inside the window. */\n completedCount: number;\n /** Count of failed runs inside the window. */\n failedCount: number;\n /** The window the counts cover, in days. Echoed for UI labels. */\n windowDays: number;\n}\n\nlet jobQueue: NpJobQueue | null = null;\n\nexport function setJobQueue(queue: NpJobQueue | null): void {\n jobQueue = queue;\n}\n\nexport function getJobQueue(): NpJobQueue {\n if (!jobQueue) {\n throw new Error(\"Job queue not initialized. Call setJobQueue() first.\");\n }\n return jobQueue;\n}\n\nexport function getOptionalJobQueue(): NpJobQueue | null {\n return jobQueue;\n}\n\n/**\n * Enqueues a job if the queue is wired up; otherwise no-ops so callers\n * (content pipeline, media processing) can run without pg-boss during MVP\n * blog-only workloads. Return value is an empty string in the no-op path.\n */\nexport async function enqueueJob(type: NpJobType, data: unknown): Promise<string> {\n if (!jobQueue) return \"\";\n return jobQueue.enqueue(type, data);\n}\n"],"mappings":";AA8PA,IAAI,WAA8B;AAE3B,SAAS,YAAY,OAAgC;AAC1D,aAAW;AACb;AAEO,SAAS,cAA0B;AACxC,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,MAAM,sDAAsD;AAAA,EACxE;AACA,SAAO;AACT;AAEO,SAAS,sBAAyC;AACvD,SAAO;AACT;AAOA,eAAsB,WAAW,MAAiB,MAAgC;AAChF,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,SAAS,QAAQ,MAAM,IAAI;AACpC;","names":[]}
|