Compare commits

..

1 Commits

Author SHA1 Message Date
Tom Moor 4811fb9f4d fix: Invisible JS error 2025-05-04 09:54:58 -04:00
24 changed files with 1042 additions and 1259 deletions
+17 -18
View File
@@ -48,18 +48,18 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.803.0",
"@aws-sdk/lib-storage": "3.803.0",
"@aws-sdk/s3-presigned-post": "3.803.0",
"@aws-sdk/s3-request-presigner": "3.803.0",
"@aws-sdk/signature-v4-crt": "^3.803.0",
"@babel/core": "^7.27.1",
"@babel/plugin-proposal-decorators": "^7.27.1",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.27.1",
"@babel/plugin-transform-regenerator": "^7.27.1",
"@babel/preset-env": "^7.27.1",
"@babel/preset-react": "^7.27.1",
"@aws-sdk/client-s3": "3.797.0",
"@aws-sdk/lib-storage": "3.797.0",
"@aws-sdk/s3-presigned-post": "3.797.0",
"@aws-sdk/s3-request-presigner": "3.797.0",
"@aws-sdk/signature-v4-crt": "^3.796.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
"@babel/plugin-transform-destructuring": "^7.25.9",
"@babel/plugin-transform-regenerator": "^7.27.0",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
@@ -208,7 +208,7 @@
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.14",
"react-medium-image-zoom": "5.2.13",
"react-merge-refs": "^2.1.1",
"react-portal": "^4.3.0",
"react-router-dom": "^5.3.4",
@@ -228,7 +228,6 @@
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"sequelize-encrypted": "^1.0.0",
"sequelize-strict-attributes": "^1.0.2",
"sequelize-typescript": "^2.1.6",
"slug": "^5.3.0",
"slugify": "^1.6.6",
@@ -249,7 +248,7 @@
"umzug": "^3.8.2",
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.15.0",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^6.3.4",
"vite-plugin-pwa": "^0.21.2",
@@ -263,8 +262,8 @@
"zod": "^3.24.2"
},
"devDependencies": {
"@babel/cli": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.3.0",
"@testing-library/react": "^12.0.0",
@@ -329,7 +328,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/utf8": "^3.0.3",
"@types/validator": "^13.15.0",
"@types/validator": "^13.12.1",
"@types/yauzl": "^2.10.3",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
+20 -20
View File
@@ -288,7 +288,7 @@ export class NotionConverter {
if (item.mention.type === "link_mention") {
return {
type: "text",
text: item.plain_text || item.mention.link_mention.href,
text: item.plain_text,
marks: [
{
type: "link",
@@ -302,7 +302,7 @@ export class NotionConverter {
if (item.mention.type === "link_preview") {
return {
type: "text",
text: item.plain_text || item.mention.link_preview.url,
text: item.plain_text,
marks: [
{
type: "link",
@@ -314,14 +314,14 @@ export class NotionConverter {
};
}
if (item.plain_text) {
return {
type: "text",
text: item.plain_text,
};
if (!item.plain_text) {
return undefined;
}
return undefined;
return {
type: "text",
text: item.plain_text,
};
}
if (item.type === "equation") {
@@ -336,20 +336,20 @@ export class NotionConverter {
};
}
if (item.text.content) {
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
if (!item.text.content) {
return undefined;
}
return undefined;
return {
type: "text",
text: item.text.content,
marks: [
...mapAttrs(),
...(item.text.link
? [{ type: "link", attrs: { href: item.text.link.url } }]
: []),
].filter(Boolean),
};
}
private static rich_text_to_plaintext(item: RichTextItemResponse) {
+30 -19
View File
@@ -1,3 +1,4 @@
import invariant from "invariant";
import { Op, WhereOptions } from "sequelize";
import isUUID from "validator/lib/isUUID";
import { UrlHelper } from "@shared/utils/UrlHelper";
@@ -21,8 +22,8 @@ type Props = {
type Result = {
document: Document;
share: Share | null;
collection: Collection | null;
share?: Share;
collection?: Collection | null;
};
export default async function loadDocument({
@@ -32,9 +33,9 @@ export default async function loadDocument({
user,
includeState,
}: Props): Promise<Result> {
let document: Document | null = null;
let collection: Collection | null = null;
let share: Share | null = null;
let document;
let collection;
let share;
if (!shareId && !(id && user)) {
throw AuthenticationError(`Authentication or shareId required`);
@@ -71,7 +72,20 @@ export default async function loadDocument({
where: whereClause,
include: [
{
model: Document.scope("withDrafts"),
// unscoping here allows us to return unpublished documents
model: Document.unscoped(),
include: [
{
model: User,
as: "createdBy",
paranoid: false,
},
{
model: User,
as: "updatedBy",
paranoid: false,
},
],
required: true,
as: "document",
},
@@ -115,13 +129,14 @@ export default async function loadDocument({
const canReadDocument = user && can(user, "read", document);
if (canReadDocument) {
// Cannot use document.collection here as it does not include the
// documentStructure by default through the relationship.
if (document.collectionId) {
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
collection = await Collection.findByPk(document.collectionId);
if (!collection) {
throw NotFoundError("Collection could not be found for document");
}
}
return {
@@ -140,15 +155,11 @@ export default async function loadDocument({
// It is possible to disable sharing at the collection so we must check
if (document.collectionId) {
collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId,
{
rejectOnEmpty: true,
}
);
collection = await Collection.findByPk(document.collectionId);
}
invariant(collection, "collection not found");
if (!collection?.sharing) {
if (!collection.sharing) {
throw AuthorizationError();
}
+11 -17
View File
@@ -1,3 +1,4 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { createContext } from "@server/context";
import { traceFunction } from "@server/logging/tracing";
@@ -65,21 +66,16 @@ async function documentMover({
result.documents.push(document);
} else {
// Load the current and the next collection upfront and lock them
const collection = await Collection.scope("withDocumentStructure").findByPk(
document.collectionId!,
{
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
}
);
const collection = await Collection.findByPk(document.collectionId!, {
transaction,
lock: Transaction.LOCK.UPDATE,
paranoid: false,
});
let newCollection = collection;
if (collectionChanged) {
if (collectionId) {
newCollection = await Collection.scope(
"withDocumentStructure"
).findByPk(collectionId, {
newCollection = await Collection.findByPk(collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -148,14 +144,12 @@ async function documentMover({
if (collectionId) {
// Reload the collection to get relationship data
newCollection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId, {
newCollection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, {
transaction,
rejectOnEmpty: true,
});
invariant(newCollection, "Collection not found");
result.collections.push(newCollection);
@@ -1,29 +0,0 @@
"use strict";
const { execFileSync } = require("child_process");
const path = require("path");
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up() {
if (
process.env.NODE_ENV === "test" ||
process.env.DEPLOYMENT === "hosted"
) {
return;
}
const scriptName = path.basename(__filename);
const scriptPath = path.join(
process.cwd(),
"build",
`server/scripts/${scriptName}`
);
execFileSync("node", [scriptPath], { stdio: "inherit" });
},
async down() {
// noop
},
};
+29 -35
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
});
describe("#url", () => {
it("should return correct url for the collection", () => {
test("should return correct url for the collection", () => {
const collection = new Collection({
id: "1234",
});
@@ -25,7 +25,7 @@ describe("#url", () => {
});
describe("getDocumentParents", () => {
it("should return array of parent document ids", async () => {
test("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -41,7 +41,7 @@ describe("getDocumentParents", () => {
expect(result ? result[0] : undefined).toBe(parent.id);
});
it("should return array of parent document ids", async () => {
test("should return array of parent document ids", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -56,7 +56,7 @@ describe("getDocumentParents", () => {
expect(result?.length).toBe(0);
});
it("should not error if documentStructure is empty", async () => {
test("should not error if documentStructure is empty", async () => {
const parent = await buildDocument();
await buildDocument();
const collection = await buildCollection();
@@ -66,7 +66,7 @@ describe("getDocumentParents", () => {
});
describe("getDocumentTree", () => {
it("should return document tree", async () => {
test("should return document tree", async () => {
const document = await buildDocument();
const collection = await buildCollection({
documentStructure: [await document.toNavigationNode()],
@@ -76,7 +76,7 @@ describe("getDocumentTree", () => {
);
});
it("should return nested documents in tree", async () => {
test("should return nested documents in tree", async () => {
const parent = await buildDocument();
const document = await buildDocument();
const collection = await buildCollection({
@@ -99,7 +99,7 @@ describe("getDocumentTree", () => {
});
describe("#addDocumentToStructure", () => {
it("should add as last element without index", async () => {
test("should add as last element without index", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -117,7 +117,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure!.length).toBe(1);
});
it("should add with an index", async () => {
test("should add with an index", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -131,7 +131,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].id).toBe(id);
});
it("should add as a child if with parent", async () => {
test("should add as a child if with parent", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -150,7 +150,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
it("should add as a child if with parent with index", async () => {
test("should add as a child if with parent with index", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -176,7 +176,7 @@ describe("#addDocumentToStructure", () => {
expect(collection.documentStructure![0].children[0].id).toBe(id);
});
it("should add the document along with its nested document(s)", async () => {
test("should add the document along with its nested document(s)", async () => {
const collection = await buildCollection();
const document = await buildDocument({
@@ -204,7 +204,7 @@ describe("#addDocumentToStructure", () => {
);
});
it("should add the document along with its archived nested document(s)", async () => {
test("should add the document along with its archived nested document(s)", async () => {
const collection = await buildCollection();
const document = await buildDocument({
@@ -237,7 +237,7 @@ describe("#addDocumentToStructure", () => {
);
});
describe("options: documentJson", () => {
it("should append supplied json over document's own", async () => {
test("should append supplied json over document's own", async () => {
const collection = await buildCollection();
const id = uuidv4();
const newDocument = await buildDocument({
@@ -268,7 +268,7 @@ describe("#addDocumentToStructure", () => {
});
describe("#updateDocument", () => {
it("should update root document's data", async () => {
test("should update root document's data", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -279,7 +279,7 @@ describe("#updateDocument", () => {
expect(collection.documentStructure![0].title).toBe("Updated title");
});
it("should update child document's data", async () => {
test("should update child document's data", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -297,7 +297,7 @@ describe("#updateDocument", () => {
newDocument.title = "Updated title";
await newDocument.save();
await collection.updateDocument(newDocument);
const reloaded = await collection.reload();
const reloaded = await Collection.findByPk(collection.id);
expect(reloaded!.documentStructure![0].children[0].title).toBe(
"Updated title"
);
@@ -305,7 +305,7 @@ describe("#updateDocument", () => {
});
describe("#removeDocument", () => {
it("should save if removing", async () => {
test("should save if removing", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -315,7 +315,7 @@ describe("#removeDocument", () => {
expect(collection.save).toBeCalled();
});
it("should remove documents from root", async () => {
test("should remove documents from root", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -331,7 +331,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(0);
});
it("should remove a document with child documents", async () => {
test("should remove a document with child documents", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -359,7 +359,7 @@ describe("#removeDocument", () => {
expect(collectionDocuments.count).toBe(0);
});
it("should remove a child document", async () => {
test("should remove a child document", async () => {
const collection = await buildCollection();
const document = await buildDocument({ collectionId: collection.id });
await collection.reload();
@@ -380,7 +380,7 @@ describe("#removeDocument", () => {
expect(collection.documentStructure![0].children.length).toBe(1);
// Remove the document
await collection.deleteDocument(newDocument);
const reloaded = await collection.reload();
const reloaded = await Collection.findByPk(collection.id);
expect(reloaded!.documentStructure!.length).toBe(1);
expect(reloaded!.documentStructure![0].children.length).toBe(0);
const collectionDocuments = await Document.findAndCountAll({
@@ -393,7 +393,7 @@ describe("#removeDocument", () => {
});
describe("#membershipUserIds", () => {
it("should return collection and group memberships", async () => {
test("should return collection and group memberships", async () => {
const team = await buildTeam();
const teamId = team.id;
// Make 6 users
@@ -464,53 +464,47 @@ describe("#membershipUserIds", () => {
});
describe("#findByPk", () => {
it("should return collection with collection Id", async () => {
test("should return collection with collection Id", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(response!.id).toBe(collection.id);
});
it("should not return documentStructure by default", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(() => response!.documentStructure).toThrow();
});
it("should return collection when urlId is present", async () => {
test("should return collection when urlId is present", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}`;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
it("should return collection when urlId is present, but missing slug", async () => {
test("should return collection when urlId is present, but missing slug", async () => {
const collection = await buildCollection();
const id = collection.urlId;
const response = await Collection.findByPk(id);
expect(response!.id).toBe(collection.id);
});
it("should return null when incorrect uuid type", async () => {
test("should return null when incorrect uuid type", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id + "-incorrect");
expect(response).toBe(null);
});
it("should return null when incorrect urlId length", async () => {
test("should return null when incorrect urlId length", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}incorrect`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
});
it("should return null when no collection is found with uuid", async () => {
test("should return null when no collection is found with uuid", async () => {
const response = await Collection.findByPk(
"a9e71a81-7342-4ea3-9889-9b9cc8f667da"
);
expect(response).toBe(null);
});
it("should return null when no collection is found with urlId", async () => {
test("should return null when no collection is found with urlId", async () => {
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
-13
View File
@@ -37,7 +37,6 @@ import {
AllowNull,
BeforeCreate,
BeforeUpdate,
DefaultScope,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort, ProsemirrorData } from "@shared/types";
@@ -70,11 +69,6 @@ type AdditionalFindOptions = {
rejectOnEmpty?: boolean | Error;
};
@DefaultScope(() => ({
attributes: {
exclude: ["documentStructure"],
},
}))
@Scopes(() => ({
withAllMemberships: {
include: [
@@ -127,12 +121,6 @@ type AdditionalFindOptions = {
},
],
}),
withDocumentStructure: () => ({
attributes: {
// resets to include the documentStructure column
exclude: [],
},
}),
withMembership: (userId: string) => {
if (!userId) {
return {};
@@ -250,7 +238,6 @@ class Collection extends ParanoidModel<
@Column
maintainerApprovalRequired: boolean;
@Default(null)
@Column(DataType.JSONB)
documentStructure: NavigationNode[] | null;
+5 -2
View File
@@ -11,6 +11,7 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -95,8 +96,10 @@ describe("#delete", () => {
await document.delete(user);
const [newDocument, newCollection] = await Promise.all([
document.reload({ paranoid: false }),
collection.reload(),
Document.findByPk(document.id, {
paranoid: false,
}),
Collection.findByPk(collection.id),
]);
expect(newDocument?.lastModifiedById).toEqual(user.id);
+25 -56
View File
@@ -15,7 +15,6 @@ import {
FindOptions,
WhereOptions,
EmptyResultError,
Sequelize,
} from "sequelize";
import {
ForeignKey,
@@ -72,20 +71,12 @@ import Length from "./validators/Length";
export const DOCUMENT_VERSION = 2;
// If content (JSON) is null then we still need to return the state column (BINARY)
// as it's used as a fallback for content deserialization for older documents.
// This can be removed if content is 100% backfilled.
const stateIfContentEmpty = Sequelize.literal(
`CASE WHEN document.content IS NULL THEN document.state ELSE NULL END AS state`
);
type AdditionalFindOptions = {
userId?: string;
includeState?: boolean;
rejectOnEmpty?: boolean | Error;
};
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
@DefaultScope(() => ({
include: [
{
@@ -110,14 +101,13 @@ type AdditionalFindOptions = {
},
},
attributes: {
include: [stateIfContentEmpty],
exclude: ["state"],
},
}))
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
@Scopes(() => ({
withoutState: {
attributes: {
include: [stateIfContentEmpty],
exclude: ["state"],
},
},
withCollection: {
@@ -131,7 +121,7 @@ type AdditionalFindOptions = {
withState: {
attributes: {
// resets to include the state column
include: [],
exclude: [],
},
},
withDrafts: {
@@ -172,13 +162,11 @@ type AdditionalFindOptions = {
return {
include: [
{
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
model: userId
? Collection.scope([
"defaultScope",
{
method: ["withMembership", userId],
},
])
? Collection.scope({
method: ["withMembership", userId],
})
: Collection,
as: "collection",
paranoid,
@@ -426,13 +414,10 @@ class Document extends ArchivableModel<
return;
}
const collection = await Collection.scope("withDocumentStructure").findByPk(
model.collectionId,
{
transaction,
lock: Transaction.LOCK.UPDATE,
}
);
const collection = await Collection.findByPk(model.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
if (!collection) {
return;
}
@@ -453,9 +438,7 @@ class Document extends ArchivableModel<
}
return this.sequelize!.transaction(async (transaction: Transaction) => {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(model.collectionId!, {
const collection = await Collection.findByPk(model.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
});
@@ -649,13 +632,9 @@ class Document extends ArchivableModel<
return uniq(membershipUserIds);
}
static withMembershipScope(
userId: string,
options?: FindOptions<Document> & { includeDrafts?: boolean }
) {
static withMembershipScope(userId: string, options?: FindOptions<Document>) {
return this.scope([
options?.includeDrafts ? "withDrafts" : "defaultScope",
"withoutState",
"defaultScope",
{
method: ["withViews", userId],
},
@@ -943,9 +922,7 @@ class Document extends ArchivableModel<
}
if (!this.template && this.collectionId) {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId, {
const collection = await Collection.findByPk(this.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
});
@@ -1012,13 +989,10 @@ class Document extends ArchivableModel<
await this.sequelize.transaction(async (transaction: Transaction) => {
const collection = this.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction.LOCK.UPDATE,
}
)
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
})
: undefined;
if (collection) {
@@ -1049,13 +1023,10 @@ class Document extends ArchivableModel<
archive = async (user: User, options?: FindOptions) => {
const { transaction } = { ...options };
const collection = this.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
this.collectionId,
{
transaction,
lock: transaction?.LOCK.UPDATE,
}
)
? await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction?.LOCK.UPDATE,
})
: undefined;
if (collection) {
@@ -1076,7 +1047,7 @@ class Document extends ArchivableModel<
) => {
const { transaction } = { ...options };
const collection = collectionId
? await Collection.scope("withDocumentStructure").findByPk(collectionId, {
? await Collection.findByPk(collectionId, {
transaction,
lock: transaction?.LOCK.UPDATE,
})
@@ -1128,9 +1099,7 @@ class Document extends ArchivableModel<
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.scope(
"withDocumentStructure"
).findByPk(this.collectionId!, {
const collection = await Collection.findByPk(this.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
+18 -16
View File
@@ -182,16 +182,16 @@ export default class SearchHelper {
},
];
return Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
include,
offset,
limit,
});
return Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where,
subQuery: false,
order: [["updatedAt", "DESC"]],
include,
offset,
limit,
});
}
public static async searchCollectionsForUser(
@@ -264,12 +264,14 @@ export default class SearchHelper {
// Final query to get associated document data
const [documents, count] = await Promise.all([
Document.withMembershipScope(user.id, { includeDrafts: true }).findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
},
}),
Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where: {
teamId: user.teamId,
id: map(results, "id"),
},
}),
results.length < limit && offset === 0
? Promise.resolve(results.length)
: countQuery,
@@ -171,8 +171,7 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
/**
* Generates a map of document urls to their path in the zip file.
*
* @param collections The collections to generate the path map for.
* @param format The format of the exported documents.
* @param collections
*/
private createPathMap(
collections: Collection[],
+5 -7
View File
@@ -44,13 +44,11 @@ export default abstract class ExportTask extends BaseTask<Props> {
? [fileOperation.collectionId]
: await user.collectionIds();
const collections = await Collection.scope("withDocumentStructure").findAll(
{
where: {
id: collectionIds,
},
}
);
const collections = await Collection.findAll({
where: {
id: collectionIds,
},
});
let filePath: string | undefined;
+3 -5
View File
@@ -140,11 +140,9 @@ router.post(
async (ctx: APIContext<T.CollectionsDocumentsReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.scope([
{
method: ["withMembership", user.id],
},
]).findByPk(id);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(id);
authorize(user, "readDocument", collection);
@@ -977,7 +977,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collectionId: document.collectionId,
collection: document.collectionId,
},
});
const body = await res.json();
@@ -1013,7 +1013,7 @@ describe("#documents.list", () => {
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
collection: collection.id,
},
});
const body = await res.json();
+12 -16
View File
@@ -133,19 +133,15 @@ router.post(
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
where[Op.and].push({ collectionId: [collectionId] });
const collection = await Collection.scope([
sort === "index" ? "withDocumentStructure" : "defaultScope",
{
method: ["withMembership", user.id],
},
]).findByPk(collectionId);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "readDocument", collection);
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (sort === "index") {
documentIds = (collection.documentStructure || [])
documentIds = (collection?.documentStructure || [])
.map((node) => node.id)
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit);
where[Op.and].push({ id: documentIds });
@@ -539,14 +535,14 @@ router.post(
delete where.updatedAt;
}
const documents = await Document.withMembershipScope(user.id, {
includeDrafts: true,
}).findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const documents = await Document.withMembershipScope(user.id)
.scope("withDrafts")
.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
documents.map((document) => presentDocument(ctx, document))
);
@@ -58,13 +58,13 @@ router.post(
const documentIds = memberships
.map((p) => p.documentId)
.filter(Boolean) as string[];
const documents = await Document.withMembershipScope(userId, {
includeDrafts: true,
}).findAll({
where: {
id: documentIds,
},
});
const documents = await Document.withMembershipScope(userId)
.scope("withDrafts")
.findAll({
where: {
id: documentIds,
},
});
const groups = uniqBy(
memberships.map((membership) => membership.group),
+1 -5
View File
@@ -54,11 +54,7 @@ router.post(
});
authorize(user, "read", document);
const collection = document.collectionId
? await Collection.scope("withDocumentStructure").findByPk(
document.collectionId
)
: undefined;
const collection = await document.$get("collection");
const parentIds = collection?.getDocumentParents(documentId);
const parentShare = parentIds
? await Share.scope({
@@ -1,127 +0,0 @@
import "./bootstrap";
import fractionalIndex from "fractional-index";
import { Sequelize, Transaction } from "sequelize";
import { Collection, Team } from "@server/models";
import { sequelize } from "@server/storage/database";
const limit = 100;
class CollectionIndexCollisionResolver {
private teamId: string;
private currDuplicateIndex: string | null = null;
private currDuplicateGroup: Collection[] = [];
private resolvedCollisionsCount: number = 0;
constructor(teamId: string) {
this.teamId = teamId;
}
public async process() {
await sequelize.transaction(async (transaction) => {
await this.processPage(0, transaction);
// edge case of last batch
await this.resolveDuplicates({ transaction });
});
}
private async processPage(
page: number,
transaction: Transaction
): Promise<void> {
console.log(
`Resolve collection index collisions for team ${this.teamId}… page ${page}`
);
const collections = await Collection.unscoped().findAll({
where: { teamId: this.teamId },
attributes: ["id", "index"],
limit,
offset: page * limit,
order: [
Sequelize.literal('"collection"."index" collate "C"'), // ensure duplicates are in sequential order
["updatedAt", "DESC"], // fallback as a tie breaker
],
lock: Transaction.LOCK.UPDATE,
transaction,
});
if (!collections.length) {
return;
}
let idx = 0;
while (idx < collections.length) {
const collection = collections[idx];
if (collection.index === this.currDuplicateIndex) {
// still in the same duplicate group.
this.currDuplicateGroup.push(collection);
} else {
// current collection index is different from the previous one; resolve duplicates, if applicable.
await this.resolveDuplicates({
nextCollection: collection,
transaction,
});
// reset the duplicate index and group.
this.currDuplicateIndex = collection.index;
this.currDuplicateGroup = [collection];
}
idx++;
}
return collections.length === limit
? this.processPage(page + 1, transaction)
: undefined;
}
private async resolveDuplicates({
nextCollection,
transaction,
}: {
nextCollection?: Collection;
transaction: Transaction;
}) {
if (this.currDuplicateGroup.length <= 1) {
// no action needed when there aren't more than 1 item in a group.
return;
}
let prevIndex = this.currDuplicateGroup[0].index;
const endIndex = nextCollection?.index ?? null;
// First collection in a duplicate group can retain its index.
for (let idx = 1; idx < this.currDuplicateGroup.length; idx++) {
const collection = this.currDuplicateGroup[idx];
const newIndex = fractionalIndex(prevIndex, endIndex);
console.log(`New index for collection ${collection.id} = ${newIndex}`);
collection.index = newIndex;
await collection.save({ silent: true, hooks: false, transaction });
prevIndex = newIndex;
}
this.resolvedCollisionsCount += this.currDuplicateGroup.length - 1;
}
}
export default async function main(exit = false) {
await Team.findAllInBatches<Team>({ batchLimit: 5 }, async (teams) => {
for (const team of teams) {
const resolver = new CollectionIndexCollisionResolver(team.id);
await resolver.process();
}
});
if (exit) {
process.exit(0);
}
}
// In the test suite we import the script rather than run via node CLI
if (process.env.NODE_ENV !== "test") {
void main(true);
}
+1 -4
View File
@@ -1,6 +1,5 @@
import path from "path";
import { InferAttributes, InferCreationAttributes } from "sequelize";
import sequelizeStrictAttributes from "sequelize-strict-attributes";
import { Sequelize } from "sequelize-typescript";
import { Umzug, SequelizeStorage, MigrationError } from "umzug";
import env from "@server/env";
@@ -24,7 +23,7 @@ export function createDatabaseInstance(
}
): Sequelize {
try {
const instance = new Sequelize(databaseUrl, {
return new Sequelize(databaseUrl, {
logging: (msg) =>
process.env.DEBUG?.includes("database") &&
Logger.debug("database", msg),
@@ -48,8 +47,6 @@ export function createDatabaseInstance(
},
schema,
});
sequelizeStrictAttributes(instance);
return instance;
} catch (error) {
Logger.fatal(
"Could not connect to database",
+2 -4
View File
@@ -310,7 +310,7 @@ export async function buildCollection(
overrides.permission = CollectionPermission.ReadWrite;
}
return Collection.scope("withDocumentStructure").create({
return Collection.create({
name: faker.lorem.words(2),
description: faker.lorem.words(4),
createdById: overrides.userId,
@@ -416,9 +416,7 @@ export async function buildDocument(
if (overrides.collectionId && overrides.publishedAt !== null) {
collection = collection
? await Collection.scope("withDocumentStructure").findByPk(
overrides.collectionId
)
? await Collection.findByPk(overrides.collectionId)
: undefined;
await collection?.addDocumentToStructure(document, 0);
@@ -191,7 +191,7 @@
"code": false,
"color": "default"
},
"plain_text": "",
"plain_text": "http://github.com/outline/",
"href": "http://github.com/outline/"
}
],
@@ -506,4 +506,4 @@
"color": "default"
}
}
]
]
+1
View File
@@ -11,6 +11,7 @@ export async function collectionIndexing(
where: {
teamId,
},
attributes: ["id", "index", "name", "teamId"],
transaction,
});
-6
View File
@@ -198,12 +198,6 @@ export const codeLanguages: Record<string, CodeLanguage> = {
label: "Powershell",
loader: () => import("refractor/lang/powershell").then((m) => m.default),
},
promql: {
lang: "promql",
label: "PromQL",
// @ts-expect-error PromQL is not in types but exists
loader: () => import("refractor/lang/promql").then((m) => m.default),
},
protobuf: {
lang: "protobuf",
label: "Protobuf",
+850 -847
View File
File diff suppressed because it is too large Load Diff