stacker.news/api/resolvers/notifications.js
ekzyis 0032e064b2
Automated retries (#1776)
* Poll failed invoices with visibility timeout

* Don't return intermediate failed invoices

* Don't retry too old invoices

* Retry invoices on client

* Only attempt payment 3 times

* Fix fallbacks during last retry

* Rename retry column to paymentAttempt

* Fix no index used

* Resolve TODOs

* Use expiring locks

* Better comments for constants

* Acquire lock during retry

* Use expiring lock in retry mutation

* Use now() instead of CURRENT_TIMESTAMP

* Cosmetic changes

* Immediately show failed post payments in notifications

* Update hasNewNotes

* Never retry on user cancel

For a consistent UX and less mental overhead, I decided to remove the exception for ITEM_CREATE where it would still retry in the background even though we want to show the payment failure immediately in notifications.

* Fix notifications without pending retries missing if no send wallets

If a stacker has no send wallets, they would miss notifications about failed payments because they would never get retried.

This commit fixes this by making the notifications query aware if the stacker has send wallets. This way, it can tell if a notification will be retried or not.

* Stop hiding userCancel in notifications

As mentioned in a previous commit, I want to show anything that will not be attempted anymore in notifications.

Before, I wanted to hide manually cancelled invoices but to not change experience unnecessarily and to decrease mental overhead, I changed my mind.

* Also consider invoice.cancelledAt in notifications

* Always retry failed payments, even without send wallets

* Fix notification indicator on retry timeout

* Set invoice.updated_at to date slightly in the future

* Use default job priority

* Stop retrying after one hour

* Remove special case for ITEM_CREATE

* Replace retryTimeout job with notification indicator query

* Fix sortTime

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-02-14 19:25:11 -06:00

598 lines
23 KiB
JavaScript

import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor'
import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item'
import { getInvoice, getWithdrawl } from './wallet'
import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
import { replyToSubscription } from '@/lib/webPush'
import { getSub } from './sub'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { WALLET_MAX_RETRIES, WALLET_RETRY_BEFORE_MS } from '@/lib/constants'
export default {
Query: {
notifications: async (parent, { cursor, inc }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
if (!me) {
throw new GqlAuthenticationError()
}
const meFull = await models.user.findUnique({ where: { id: me.id } })
/*
So that we can cursor over results, we union notifications together ...
this requires we have the same number of columns in all results
select "Item".id, NULL as earnedSats, "Item".created_at as created_at from
"Item" JOIN "Item" p ON "Item"."parentId" = p.id AND p."userId" = 622 AND
"Item"."userId" <> 622 UNION ALL select "Item".id, "Vote".sats as earnedSats,
"Vote".created_at as created_at FROM "Item" LEFT JOIN "Vote" on
"Vote"."itemId" = "Item".id AND "Vote"."userId" <> 622 AND "Vote".boost = false
WHERE "Item"."userId" = 622 ORDER BY created_at DESC;
Because we want to "collapse" time adjacent votes in the result
select vote.id, sum(vote."earnedSats") as "earnedSats", max(vote.voted_at)
as "createdAt" from (select "Item".*, "Vote".sats as "earnedSats",
"Vote".created_at as voted_at, ROW_NUMBER() OVER(ORDER BY "Vote".created_at) -
ROW_NUMBER() OVER(PARTITION BY "Item".id ORDER BY "Vote".created_at) as island
FROM "Item" LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND
"Vote"."userId" <> 622 AND "Vote".boost = false WHERE "Item"."userId" = 622)
as vote group by vote.id, vote.island order by max(vote.voted_at) desc;
We can also "collapse" votes occuring within 1 hour intervals of each other
(I haven't yet combined with the above collapsing method .. but might be
overkill)
select "Item".id, sum("Vote".sats) as earnedSats, max("Vote".created_at)
as created_at, ROW_NUMBER() OVER(ORDER BY max("Vote".created_at)) - ROW_NUMBER()
OVER(PARTITION BY "Item".id ORDER BY max("Vote".created_at)) as island FROM
"Item" LEFT JOIN "Vote" on "Vote"."itemId" = "Item".id AND "Vote"."userId" <> 622
AND "Vote".boost = false WHERE "Item"."userId" = 622 group by "Item".id,
date_trunc('hour', "Vote".created_at) order by created_at desc;
island approach we used to take
(SELECT ${ITEM_SUBQUERY_FIELDS}, max(subquery.voted_at) as "sortTime",
sum(subquery.sats) as "earnedSats", false as mention
FROM
(SELECT ${ITEM_FIELDS}, "ItemAct".created_at as voted_at, "ItemAct".sats,
ROW_NUMBER() OVER(ORDER BY "ItemAct".created_at) -
ROW_NUMBER() OVER(PARTITION BY "Item".id ORDER BY "ItemAct".created_at) as island
FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" <> $1
AND "ItemAct".created_at <= $2
AND "ItemAct".act <> 'BOOST'
AND "Item"."userId" = $1) subquery
GROUP BY ${ITEM_SUBQUERY_FIELDS}, subquery.island
ORDER BY max(subquery.voted_at) desc
LIMIT ${LIMIT}+$3)
*/
// HACK to make notifications faster, we only return a limited sub set of the unioned
// queries ... we only ever need at most LIMIT+current offset in the child queries to
// have enough items to return in the union
const queries = []
const itemDrivenQueries = []
// Thread subscriptions
itemDrivenQueries.push(
`SELECT "Item".*, "Item".created_at AS "sortTime", 'Reply' AS type
FROM "ThreadSubscription"
JOIN "Reply" r ON "ThreadSubscription"."itemId" = r."ancestorId"
JOIN "Item" ON r."itemId" = "Item".id
${whereClause(
'"ThreadSubscription"."userId" = $1',
'r.created_at >= "ThreadSubscription".created_at',
'r.created_at < $2',
'r."userId" <> $1',
...(meFull.noteAllDescendants ? [] : ['r.level = 1'])
)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}`
)
// User subscriptions
// Only include posts or comments created after the corresponding subscription was enabled, not _all_ from history
itemDrivenQueries.push(
`SELECT "Item".*, "Item".created_at AS "sortTime", 'FollowActivity' AS type
FROM "Item"
JOIN "UserSubscription" ON "Item"."userId" = "UserSubscription"."followeeId"
${whereClause(
'"Item".created_at < $2',
'"UserSubscription"."followerId" = $1',
`(
("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt")
OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt")
)`
)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}`
)
// Territory subscriptions
itemDrivenQueries.push(
`SELECT "Item".*, "Item".created_at AS "sortTime", 'TerritoryPost' AS type
FROM "Item"
JOIN "SubSubscription" ON "Item"."subName" = "SubSubscription"."subName"
${whereClause(
'"Item".created_at < $2',
'"SubSubscription"."userId" = $1',
'"Item"."userId" <> $1',
'"Item"."parentId" IS NULL',
'"Item".created_at >= "SubSubscription".created_at'
)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}`
)
// mentions
if (meFull.noteMentions) {
itemDrivenQueries.push(
`SELECT "Item".*, "Mention".created_at AS "sortTime", 'Mention' AS type
FROM "Mention"
JOIN "Item" ON "Mention"."itemId" = "Item".id
${whereClause(
'"Item".created_at < $2',
'"Mention"."userId" = $1',
'"Item"."userId" <> $1'
)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}`
)
}
// item mentions
if (meFull.noteItemMentions) {
itemDrivenQueries.push(
`SELECT "Referrer".*, "ItemMention".created_at AS "sortTime", 'ItemMention' AS type
FROM "ItemMention"
JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id
JOIN "Item" "Referrer" ON "ItemMention"."referrerId" = "Referrer".id
${whereClause(
'"ItemMention".created_at < $2',
'"Referrer"."userId" <> $1',
'"Referee"."userId" = $1'
)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}`
)
}
// Inner union to de-dupe item-driven notifications
queries.push(
// Only record per item ID
`(
SELECT DISTINCT ON (id) "Item".id::TEXT, "Item"."sortTime", NULL::BIGINT AS "earnedSats", "Item".type
FROM (
${itemDrivenQueries.map(q => `(${q})`).join(' UNION ALL ')}
) as "Item"
${whereClause(
'"Item".created_at < $2',
await filterClause(me, models),
muteClause(me),
activeOrMine(me))}
ORDER BY id ASC, CASE
WHEN type = 'Mention' THEN 1
WHEN type = 'Reply' THEN 2
WHEN type = 'FollowActivity' THEN 3
WHEN type = 'TerritoryPost' THEN 4
WHEN type = 'ItemMention' THEN 5
END ASC
)`
)
// territory transfers
queries.push(
`(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats",
'TerritoryTransfer' AS type
FROM "TerritoryTransfer"
WHERE "TerritoryTransfer"."newUserId" = $1
AND "TerritoryTransfer"."created_at" <= $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
if (meFull.noteItemSats) {
queries.push(
`(SELECT "Item".id::TEXT, "Item"."lastZapAt" AS "sortTime",
"Item".msats/1000 as "earnedSats", 'Votification' AS type
FROM "Item"
WHERE "Item"."userId" = $1
AND "Item"."lastZapAt" < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
if (meFull.noteForwardedSats) {
queries.push(
`(SELECT "Item".id::TEXT, "Item"."lastZapAt" AS "sortTime",
("Item".msats / 1000 * "ItemForward".pct / 100) as "earnedSats", 'ForwardedVotification' AS type
FROM "Item"
JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = $1
WHERE "Item"."userId" <> $1
AND "Item"."lastZapAt" < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
if (meFull.noteDeposits) {
queries.push(
`(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime",
FLOOR("Invoice"."msatsReceived" / 1000) as "earnedSats",
'InvoicePaid' AS type
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "Invoice"."confirmedAt" IS NOT NULL
AND "Invoice"."created_at" < $2
AND (
("Invoice"."isHeld" IS NULL AND "Invoice"."actionType" IS NULL)
OR (
"Invoice"."actionType" = 'RECEIVE'
AND "Invoice"."actionState" = 'PAID'
)
)
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
if (meFull.noteWithdrawals) {
queries.push(
`(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime",
FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats",
'WithdrawlPaid' AS type
FROM "Withdrawl"
LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id
LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id
WHERE "Withdrawl"."userId" = $1
AND "Withdrawl".status = 'CONFIRMED'
AND "Withdrawl".created_at < $2
AND "InvoiceForward"."id" IS NULL
GROUP BY "Withdrawl".id
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
if (meFull.noteInvites) {
queries.push(
`(SELECT "Invite".id, MAX(users.created_at) AS "sortTime", NULL as "earnedSats",
'Invitification' AS type
FROM users JOIN "Invite" on users."inviteId" = "Invite".id
WHERE "Invite"."userId" = $1
AND users.created_at < $2
GROUP BY "Invite".id
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats",
'Referral' AS type
FROM users
WHERE "users"."referrerId" = $1
AND "inviteId" IS NULL
AND users.created_at < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
if (meFull.noteEarning) {
queries.push(
`(SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats",
'Earn' AS type
FROM "Earn"
WHERE "userId" = $1
AND created_at < $2
AND (type IS NULL OR type NOT IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL'))
GROUP BY "userId", created_at
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats",
'Revenue' AS type
FROM "SubAct"
WHERE "userId" = $1
AND type = 'REVENUE'
AND created_at < $2
GROUP BY "userId", "subName", created_at
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats",
'ReferralReward' AS type
FROM "Earn"
WHERE "userId" = $1
AND created_at < $2
AND type IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL')
GROUP BY "userId", created_at
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
if (meFull.noteCowboyHat) {
queries.push(
`(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
}
queries.push(
`(SELECT "Sub".name::text, "Sub"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats",
'SubStatus' AS type
FROM "Sub"
WHERE "Sub"."userId" = $1
AND "status" <> 'ACTIVE'
AND "statusUpdatedAt" < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT "Reminder".id::text, "Reminder"."remindAt" AS "sortTime", NULL as "earnedSats", 'Reminder' AS type
FROM "Reminder"
WHERE "Reminder"."userId" = $1
AND "Reminder"."remindAt" < $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
queries.push(
`(SELECT "Invoice".id::text,
CASE
WHEN
"Invoice"."paymentAttempt" < ${WALLET_MAX_RETRIES}
AND "Invoice"."userCancel" = false
AND "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
THEN "Invoice"."cancelledAt" + interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
ELSE "Invoice"."updated_at"
END AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "Invoice"."updated_at" < $2
AND "Invoice"."actionState" = 'FAILED'
AND (
-- this is the inverse of the filter for automated retries
"Invoice"."paymentAttempt" >= ${WALLET_MAX_RETRIES}
OR "Invoice"."userCancel" = true
OR "Invoice"."cancelledAt" <= now() - interval '${`${WALLET_RETRY_BEFORE_MS} milliseconds`}'
)
AND (
"Invoice"."actionType" = 'ITEM_CREATE' OR
"Invoice"."actionType" = 'ZAP' OR
"Invoice"."actionType" = 'DOWN_ZAP' OR
"Invoice"."actionType" = 'POLL_VOTE' OR
"Invoice"."actionType" = 'BOOST'
)
ORDER BY "sortTime" DESC
LIMIT ${LIMIT})`
)
const notifications = await models.$queryRawUnsafe(
`SELECT id, "sortTime", "earnedSats", type,
"sortTime" AS "minSortTime"
FROM
(${queries.join(' UNION ALL ')}) u
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}`, me.id, decodedCursor.time)
if (decodedCursor.offset === 0) {
await models.user.update({ where: { id: me.id }, data: { checkedNotesAt: new Date() } })
}
return {
lastChecked: meFull.checkedNotesAt,
cursor: notifications.length === LIMIT ? nextNoteCursorEncoded(decodedCursor, notifications) : null,
notifications
}
}
},
Mutation: {
savePushSubscription: async (parent, { endpoint, p256dh, auth, oldEndpoint }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
await validateSchema(pushSubscriptionSchema, { endpoint, p256dh, auth })
let dbPushSubscription
if (oldEndpoint) {
dbPushSubscription = await models.pushSubscription.update({
data: { userId: me.id, endpoint, p256dh, auth }, where: { endpoint: oldEndpoint }
})
console.log(`[webPush] updated subscription of user ${me.id}: old=${oldEndpoint} new=${endpoint}`)
} else {
dbPushSubscription = await models.pushSubscription.create({
data: { userId: me.id, endpoint, p256dh, auth }
})
console.log(`[webPush] created subscription for user ${me.id}: endpoint=${endpoint}`)
}
await replyToSubscription(dbPushSubscription.id, { title: 'Stacker News notifications are now active' })
return dbPushSubscription
},
deletePushSubscription: async (parent, { endpoint }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
const subscription = await models.pushSubscription.findFirst({ where: { endpoint, userId: Number(me.id) } })
if (!subscription) {
throw new GqlInputError('endpoint not found')
}
const deletedSubscription = await models.pushSubscription.delete({ where: { id: subscription.id } })
console.log(`[webPush] deleted subscription ${deletedSubscription.id} of user ${deletedSubscription.userId} due to client request`)
return deletedSubscription
}
},
Notification: {
__resolveType: async (n, args, { models }) => n.type
},
Votification: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
},
ForwardedVotification: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
},
Reply: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
},
FollowActivity: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
},
TerritoryPost: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
},
Reminder: {
item: async (n, args, { models, me }) => {
const { itemId } = await models.reminder.findUnique({ where: { id: Number(n.id) } })
return await getItem(n, { id: itemId }, { models, me })
}
},
TerritoryTransfer: {
sub: async (n, args, { models, me }) => {
const transfer = await models.territoryTransfer.findUnique({ where: { id: Number(n.id) }, include: { sub: true } })
return transfer.sub
}
},
JobChanged: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
},
SubStatus: {
sub: async (n, args, { models, me }) => getSub(n, { name: n.id }, { models, me })
},
Revenue: {
subName: async (n, args, { models }) => {
const subAct = await models.subAct.findUnique({
where: {
id: Number(n.id)
}
})
return subAct.subName
}
},
ReferralSource: {
__resolveType: async (n, args, { models }) => n.type
},
Referral: {
source: async (n, args, { models, me }) => {
// retrieve the referee landing record
const referral = await models.oneDayReferral.findFirst({ where: { refereeId: Number(n.id), landing: true } })
if (!referral) return null // if no landing record, it will return a generic referral
switch (referral.type) {
case 'POST':
case 'COMMENT': return { ...await getItem(n, { id: referral.typeId }, { models, me }), type: 'Item' }
case 'TERRITORY': return { ...await getSub(n, { name: referral.typeId }, { models, me }), type: 'Sub' }
case 'PROFILE': return { ...await models.user.findUnique({ where: { id: Number(referral.typeId) }, select: { name: true } }), type: 'User' }
default: return null
}
}
},
Streak: {
days: async (n, args, { models }) => {
const res = await models.$queryRaw`
SELECT "endedAt" - "startedAt" AS days
FROM "Streak"
WHERE id = ${Number(n.id)} AND "endedAt" IS NOT NULL
`
return res.length ? res[0].days : null
},
type: async (n, args, { models }) => {
const res = await models.$queryRaw`
SELECT "type"
FROM "Streak"
WHERE id = ${Number(n.id)}
`
return res.length ? res[0].type : null
}
},
Earn: {
sources: async (n, args, { me, models }) => {
const [sources] = await models.$queryRawUnsafe(`
SELECT
FLOOR(sum(msats) FILTER(WHERE type = 'POST') / 1000) AS posts,
FLOOR(sum(msats) FILTER(WHERE type = 'COMMENT') / 1000) AS comments,
FLOOR(sum(msats) FILTER(WHERE type = 'TIP_POST') / 1000) AS "tipPosts",
FLOOR(sum(msats) FILTER(WHERE type = 'TIP_COMMENT') / 1000) AS "tipComments"
FROM "Earn"
WHERE "userId" = $1 AND created_at <= $2 AND created_at >= $3
`, Number(me.id), new Date(n.sortTime), new Date(n.minSortTime))
sources.posts ||= 0
sources.comments ||= 0
sources.tipPosts ||= 0
sources.tipComments ||= 0
if (sources.posts + sources.comments + sources.tipPosts + sources.tipComments > 0) {
return sources
}
return null
}
},
ReferralReward: {
sources: async (n, args, { me, models }) => {
const [sources] = await models.$queryRawUnsafe(`
SELECT
COALESCE(FLOOR(sum(msats) FILTER(WHERE type = 'FOREVER_REFERRAL') / 1000), 0) AS forever,
COALESCE(FLOOR(sum(msats) FILTER(WHERE type = 'ONE_DAY_REFERRAL') / 1000), 0) AS "oneDay"
FROM "Earn"
WHERE "userId" = $1 AND created_at = $2
`, Number(me.id), new Date(n.sortTime))
if (sources.forever + sources.oneDay > 0) {
return sources
}
return null
}
},
Mention: {
mention: async (n, args, { models }) => true,
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
},
ItemMention: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
},
InvoicePaid: {
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
},
Invoicification: {
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
},
WithdrawlPaid: {
withdrawl: async (n, args, { me, models }) => getWithdrawl(n, { id: n.id }, { me, models })
},
Invitification: {
invite: async (n, args, { models }) => {
return await models.invite.findUnique({
where: {
id: n.id
}
})
}
}
}
// const ITEM_SUBQUERY_FIELDS =
// `subquery.id, subquery."createdAt", subquery."updatedAt", subquery.title, subquery.text,
// subquery.url, subquery."userId", subquery."parentId", subquery.path`
// const ITEM_GROUP_FIELDS =
// `"Item".id, "Item".created_at, "Item".updated_at, "Item".title,
// "Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path")`
// const ITEM_FIELDS =
// `"Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
// "Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS path`