joist-core 2.4.0-next.10 → 2.4.0-next.12

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.
@@ -3,6 +3,7 @@ const require_relations_Reference = require("./relations/Reference.cjs");
3
3
  require("./relations/index.cjs");
4
4
  const require_utils = require("./utils.cjs");
5
5
  const require_EntityMetadata = require("./EntityMetadata.cjs");
6
+ const require_dataloaders_fastWhereFilterHash = require("./dataloaders/fastWhereFilterHash.cjs");
6
7
  const require_Entity = require("./Entity.cjs");
7
8
  //#region src/IndexManager.ts
8
9
  const emptySet = /* @__PURE__ */ new Set();
@@ -80,7 +81,7 @@ var IndexManager = class {
80
81
  if (!entry) {
81
82
  entry = {
82
83
  field,
83
- index: new FieldIndex(),
84
+ index: new FieldIndex(meta.columns[fieldName]?.isArray ?? false),
84
85
  indexedCount: 0
85
86
  };
86
87
  indexes.set(fieldName, entry);
@@ -132,7 +133,12 @@ function getFieldValue(entity, fieldName, field) {
132
133
  * - relations not having loaded their instances yet
133
134
  */
134
135
  var FieldIndex = class {
136
+ isArray;
135
137
  #valueToEntities = /* @__PURE__ */ new Map();
138
+ #arrayToEntities = /* @__PURE__ */ new Map();
139
+ constructor(isArray) {
140
+ this.isArray = isArray;
141
+ }
136
142
  /** @return entities that have `value` as their current value for this field. */
137
143
  get(value) {
138
144
  if (require_Entity.isEntity(value) && value.idTaggedMaybe) {
@@ -142,6 +148,8 @@ var FieldIndex = class {
142
148
  return matchId ?? matchInstance;
143
149
  }
144
150
  if (value === null) value = void 0;
151
+ const arrayKey = this.maybeArrayKey(value);
152
+ if (arrayKey !== void 0) return this.#arrayToEntities.get(arrayKey);
145
153
  return this.#valueToEntities.get(value);
146
154
  }
147
155
  add(value, entity) {
@@ -153,17 +161,27 @@ var FieldIndex = class {
153
161
  this.doRemove(value, entity);
154
162
  }
155
163
  doAdd(value, entity) {
156
- const set = this.#valueToEntities.get(value) ?? /* @__PURE__ */ new Set();
157
- if (set.size === 0) this.#valueToEntities.set(value, set);
164
+ const arrayKey = this.maybeArrayKey(value);
165
+ const index = arrayKey === void 0 ? this.#valueToEntities : this.#arrayToEntities;
166
+ const key = arrayKey ?? value;
167
+ const set = index.get(key) ?? /* @__PURE__ */ new Set();
168
+ if (set.size === 0) index.set(key, set);
158
169
  set.add(entity);
159
170
  }
160
171
  doRemove(value, entity) {
161
- const set = this.#valueToEntities.get(value);
172
+ const arrayKey = this.maybeArrayKey(value);
173
+ const index = arrayKey === void 0 ? this.#valueToEntities : this.#arrayToEntities;
174
+ const key = arrayKey ?? value;
175
+ const set = index.get(key);
162
176
  if (set) {
163
177
  set.delete(entity);
164
- if (set.size === 0) this.#valueToEntities.delete(value);
178
+ if (set.size === 0) index.delete(key);
165
179
  }
166
180
  }
181
+ /** Preserves array order and element types, including bigint values that JSON cannot encode. */
182
+ maybeArrayKey(value) {
183
+ return this.isArray && Array.isArray(value) ? require_dataloaders_fastWhereFilterHash.fastWhereFilterHash(value) : void 0;
184
+ }
167
185
  };
168
186
  function intersectSets(set1, set2) {
169
187
  const [smaller, larger] = set1.size <= set2.size ? [set1, set2] : [set2, set1];
@@ -1 +1 @@
1
- {"version":3,"file":"IndexManager.cjs","names":["#indexes","groupBy","getMetadata","isLoadedReference","#valueToEntities","isEntity"],"sources":["../src/IndexManager.ts"],"sourcesContent":["import { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityMetadata, type Field, getMetadata } from \"src/EntityMetadata.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { groupBy } from \"src/utils.ts\";\n\ntype FieldValue = any;\ntype FieldName = string;\ntype EntityTag = string;\ntype FieldIndexEntry = {\n field: Field;\n index: FieldIndex;\n /** Tracks how far into the EM's list-per-tag array this field index has been lazily populated. */\n indexedCount: number;\n};\nconst emptySet = new Set<Entity>();\n\n// The test reproducing a n^2 with n=500 went from 100ms to 50ms if indexed\nexport const indexThreshold = 500;\n\n/**\n * IndexManager provides field-based indexing for entity queries to avoid O(n) linear scans of `em.entities`.\n *\n * Key features:\n * - Only indexes entity types with >1000 entities to avoid overhead for small datasets\n * - Supports dual indexing for m2o/poly fields (by ID for saved, by instance for unsaved)\n * - Automatically maintains indexes when fields are updated via setField()\n */\nexport class IndexManager {\n readonly #indexes: Map<EntityTag, Map<FieldName, FieldIndexEntry>> = new Map();\n\n /** @return if we should index entities of this type/count. */\n shouldIndexType(entityCount: number): boolean {\n return entityCount >= indexThreshold;\n }\n\n /** Visible for testing. */\n isIndexed(tagName: string): boolean {\n return this.#indexes.has(tagName);\n }\n\n /** Enables indexing for an entity type and builds indexes for the requested fields. */\n enableIndexingForType<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: object): void {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n this.#indexes.set(tagName, new Map());\n }\n\n const fieldNames = Object.keys(where);\n const fieldIndexes = this.#indexes.get(tagName)!;\n const missingFieldNames = fieldNames.filter((fieldName) => !fieldIndexes.has(fieldName));\n if (missingFieldNames.length === 0) return;\n\n // If subtypes are involved, group by each subtype\n if (meta.baseType || meta.subTypes.length > 0) {\n [...groupBy(entities, (e) => getMetadata(e)).entries()].forEach(([meta, entities]) => {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n });\n } else {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n }\n }\n\n /** Updates indexes when a field value changes. */\n maybeUpdateFieldIndex(entity: Entity, fieldName: string, oldValue: any, newValue: any): void {\n // Fast return for the common case, which is no indexing\n if (this.#indexes.size === 0) return;\n\n const meta = getMetadata(entity);\n const fieldIndexes = this.#indexes.get(meta.tagName);\n if (!fieldIndexes) return; // Type not indexed\n\n const fieldIndex = fieldIndexes.get(fieldName)?.index;\n if (!fieldIndex) return; // Field is not indexed\n\n fieldIndex.remove(oldValue, entity);\n fieldIndex.add(newValue, entity);\n }\n\n /**\n * Finds entities matching the given where clause using indexes.\n *\n * Returns the raw candidate set (possibly a live internal index set, so do not mutate it);\n * the caller applies its own subtype/deleted filtering while building the final array.\n */\n findMatching<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: any): ReadonlySet<T> {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n throw new Error(`${meta.type} is not indexed`);\n }\n const fieldIndexes = this.#indexes.get(tagName)!;\n // Start with all entities of the 1st condition, then intersect (AND) each subsequent field in `where`\n let candidates: Set<Entity> | undefined;\n for (const [fieldName, value] of Object.entries(where)) {\n this.indexUnindexedEntities(entities, fieldName);\n const fieldCandidates = fieldIndexes.get(fieldName)?.index.get(value);\n if (!candidates) {\n candidates = fieldCandidates ?? emptySet; // This is the 1st clause\n } else {\n candidates = fieldCandidates ? intersectSets(candidates, fieldCandidates) : emptySet;\n }\n // Early exit if no candidates remain\n if (candidates.size === 0) break;\n }\n if (candidates === undefined) {\n throw new Error(`Expected where clause with at least one condition`);\n }\n return candidates as unknown as ReadonlySet<T>;\n }\n\n // `entities` should all be of the exact same subtype\n private addEntitiesToIndex(meta: EntityMetadata, entities: Entity[], fieldNames: string[]): void {\n const { tagName } = meta;\n const indexes = this.#indexes.get(tagName)!;\n // Iterate over each field so we can do a shared fieldIndex lookup\n for (const fieldName of fieldNames) {\n const field = meta.allFields[fieldName];\n if (!field) continue;\n let entry = indexes.get(fieldName);\n if (!entry) {\n entry = { field, index: new FieldIndex(), indexedCount: 0 };\n indexes.set(fieldName, entry);\n }\n for (const entity of entities) {\n // Deleted entities never match, and reading a flushed one's relations throws\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n }\n\n /** Lazily indexes newly-registered entities only for fields that are queried. */\n private indexUnindexedEntities<T extends Entity>(entities: T[], fieldName: string): void {\n const first = entities[0];\n if (first === undefined) return;\n\n const meta = getMetadata(first);\n const entry = this.#indexes.get(meta.tagName)?.get(fieldName);\n if (entry === undefined || entry.indexedCount === entities.length) return;\n\n for (let i = entry.indexedCount; i < entities.length; i++) {\n const entity = entities[i];\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n}\n\n// Use the same field access pattern as entityMatches\nfunction getFieldValue(entity: Entity, fieldName: string, field: Field): any {\n const fn = fieldName as keyof Entity;\n switch (field.kind) {\n case \"primaryKey\":\n return entity.idTaggedMaybe;\n case \"enum\":\n case \"primitive\":\n return (entity as any)[fn];\n case \"m2o\":\n case \"poly\":\n const relation = (entity as any)[fn] as\n | ManyToOneReference<Entity, any, any>\n | PolymorphicReference<Entity, any, any>;\n if (isLoadedReference(relation)) {\n return relation.get;\n } else if (relation.isSet) {\n return relation.id;\n } else {\n return undefined;\n }\n default:\n return undefined;\n }\n}\n\n/**\n * For a given field, like `firstName`, holds the reverse index of value `bob` -> entities `[a1, a2]`.\n *\n * This also handles indexes `Entity` values as both instances & IDs, to handle both:\n * - new/unpersisted entities not yet having IDs, and\n * - relations not having loaded their instances yet\n */\nclass FieldIndex {\n readonly #valueToEntities = new Map<FieldValue, Set<Entity>>();\n\n /** @return entities that have `value` as their current value for this field. */\n get(value: any): Set<Entity> | undefined {\n if (isEntity(value) && value.idTaggedMaybe) {\n // If `value` is a persisted entity like `a:1`, and entities could have indexed their `author_id=a:1` field\n // values as the unloaded \"just an a:1 string id\" value, before `a:1` was loaded into memory.\n const matchId = this.#valueToEntities.get(value.idTaggedMaybe);\n // But other entities could also have indexes `a:1` as an instance, so go ahead and check both\n const matchInstance = this.#valueToEntities.get(value);\n if (matchId && matchInstance) {\n return new Set([...matchId, ...matchInstance]);\n }\n return matchId ?? matchInstance;\n }\n // Treat null and undefined as equivalent for unset relations\n if (value === null) value = undefined;\n return this.#valueToEntities.get(value);\n }\n\n add(value: any, entity: Entity): void {\n // If this is an entity, store both the entity itself & its ID\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doAdd(value.idTaggedMaybe, entity);\n }\n this.doAdd(value, entity);\n }\n\n remove(value: any, entity: Entity): void {\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doRemove(value.idTaggedMaybe, entity);\n }\n this.doRemove(value, entity);\n }\n\n private doAdd(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value) ?? new Set();\n if (set.size === 0) this.#valueToEntities.set(value, set);\n set.add(entity);\n }\n\n private doRemove(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value);\n if (set) {\n set.delete(entity);\n if (set.size === 0) {\n this.#valueToEntities.delete(value);\n }\n }\n }\n}\n\nfunction intersectSets<T>(set1: Set<T>, set2: Set<T>): Set<T> {\n const [smaller, larger] = set1.size <= set2.size ? [set1, set2] : [set2, set1];\n const result = new Set<T>();\n for (const item of smaller) {\n if (larger.has(item)) {\n result.add(item);\n }\n }\n return result;\n}\n"],"mappings":";;;;;;;AAcA,MAAM,2BAAW,IAAI,IAAY;AAGjC,MAAa,iBAAiB;;;;;;;;;AAU9B,IAAa,eAAb,MAA0B;CACxB,2BAAqE,IAAI,IAAI;;CAG7E,gBAAgB,aAA8B;EAC5C,OAAO,eAAA;CACT;;CAGA,UAAU,SAA0B;EAClC,OAAO,KAAKA,SAAS,IAAI,OAAO;CAClC;;CAGA,sBAAwC,MAAyB,UAAe,OAAqB;EACnG,MAAM,EAAE,YAAY;EACpB,IAAI,CAAC,KAAKA,SAAS,IAAI,OAAO,GAC5B,KAAKA,SAAS,IAAI,yBAAS,IAAI,IAAI,CAAC;EAGtC,MAAM,aAAa,OAAO,KAAK,KAAK;EACpC,MAAM,eAAe,KAAKA,SAAS,IAAI,OAAO;EAC9C,MAAM,oBAAoB,WAAW,QAAQ,cAAc,CAAC,aAAa,IAAI,SAAS,CAAC;EACvF,IAAI,kBAAkB,WAAW,GAAG;EAGpC,IAAI,KAAK,YAAY,KAAK,SAAS,SAAS,GAC1C,CAAC,GAAGC,cAAAA,QAAQ,WAAW,MAAMC,uBAAAA,YAAY,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,cAAc;GACpF,KAAK,mBAAmB,MAAM,UAAU,iBAAiB;EAC3D,CAAC;OAED,KAAK,mBAAmB,MAAM,UAAU,iBAAiB;CAE7D;;CAGA,sBAAsB,QAAgB,WAAmB,UAAe,UAAqB;EAE3F,IAAI,KAAKF,SAAS,SAAS,GAAG;EAE9B,MAAM,OAAOE,uBAAAA,YAAY,MAAM;EAC/B,MAAM,eAAe,KAAKF,SAAS,IAAI,KAAK,OAAO;EACnD,IAAI,CAAC,cAAc;EAEnB,MAAM,aAAa,aAAa,IAAI,SAAS,CAAC,EAAE;EAChD,IAAI,CAAC,YAAY;EAEjB,WAAW,OAAO,UAAU,MAAM;EAClC,WAAW,IAAI,UAAU,MAAM;CACjC;;;;;;;CAQA,aAA+B,MAAyB,UAAe,OAA4B;EACjG,MAAM,EAAE,YAAY;EACpB,IAAI,CAAC,KAAKA,SAAS,IAAI,OAAO,GAC5B,MAAM,IAAI,MAAM,GAAG,KAAK,KAAK,gBAAgB;EAE/C,MAAM,eAAe,KAAKA,SAAS,IAAI,OAAO;EAE9C,IAAI;EACJ,KAAK,MAAM,CAAC,WAAW,UAAU,OAAO,QAAQ,KAAK,GAAG;GACtD,KAAK,uBAAuB,UAAU,SAAS;GAC/C,MAAM,kBAAkB,aAAa,IAAI,SAAS,CAAC,EAAE,MAAM,IAAI,KAAK;GACpE,IAAI,CAAC,YACH,aAAa,mBAAmB;QAEhC,aAAa,kBAAkB,cAAc,YAAY,eAAe,IAAI;GAG9E,IAAI,WAAW,SAAS,GAAG;EAC7B;EACA,IAAI,eAAe,KAAA,GACjB,MAAM,IAAI,MAAM,mDAAmD;EAErE,OAAO;CACT;CAGA,mBAA2B,MAAsB,UAAoB,YAA4B;EAC/F,MAAM,EAAE,YAAY;EACpB,MAAM,UAAU,KAAKA,SAAS,IAAI,OAAO;EAEzC,KAAK,MAAM,aAAa,YAAY;GAClC,MAAM,QAAQ,KAAK,UAAU;GAC7B,IAAI,CAAC,OAAO;GACZ,IAAI,QAAQ,QAAQ,IAAI,SAAS;GACjC,IAAI,CAAC,OAAO;IACV,QAAQ;KAAE;KAAO,OAAO,IAAI,WAAW;KAAG,cAAc;IAAE;IAC1D,QAAQ,IAAI,WAAW,KAAK;GAC9B;GACA,KAAK,MAAM,UAAU,UAAU;IAE7B,IAAI,OAAO,iBAAiB;IAC5B,MAAM,QAAQ,cAAc,QAAQ,WAAW,MAAM,KAAK;IAC1D,MAAM,MAAM,IAAI,OAAO,MAAM;GAC/B;GACA,MAAM,eAAe,SAAS;EAChC;CACF;;CAGA,uBAAiD,UAAe,WAAyB;EACvF,MAAM,QAAQ,SAAS;EACvB,IAAI,UAAU,KAAA,GAAW;EAEzB,MAAM,OAAOE,uBAAAA,YAAY,KAAK;EAC9B,MAAM,QAAQ,KAAKF,SAAS,IAAI,KAAK,OAAO,CAAC,EAAE,IAAI,SAAS;EAC5D,IAAI,UAAU,KAAA,KAAa,MAAM,iBAAiB,SAAS,QAAQ;EAEnE,KAAK,IAAI,IAAI,MAAM,cAAc,IAAI,SAAS,QAAQ,KAAK;GACzD,MAAM,SAAS,SAAS;GACxB,IAAI,OAAO,iBAAiB;GAC5B,MAAM,QAAQ,cAAc,QAAQ,WAAW,MAAM,KAAK;GAC1D,MAAM,MAAM,IAAI,OAAO,MAAM;EAC/B;EACA,MAAM,eAAe,SAAS;CAChC;AACF;AAGA,SAAS,cAAc,QAAgB,WAAmB,OAAmB;CAC3E,MAAM,KAAK;CACX,QAAQ,MAAM,MAAd;EACE,KAAK,cACH,OAAO,OAAO;EAChB,KAAK;EACL,KAAK,aACH,OAAQ,OAAe;EACzB,KAAK;EACL,KAAK;GACH,MAAM,WAAY,OAAe;GAGjC,IAAIG,4BAAAA,kBAAkB,QAAQ,GAC5B,OAAO,SAAS;QACX,IAAI,SAAS,OAClB,OAAO,SAAS;QAEhB;EAEJ,SACE;CACJ;AACF;;;;;;;;AASA,IAAM,aAAN,MAAiB;CACf,mCAA4B,IAAI,IAA6B;;CAG7D,IAAI,OAAqC;EACvC,IAAIE,eAAAA,SAAS,KAAK,KAAK,MAAM,eAAe;GAG1C,MAAM,UAAU,KAAKD,iBAAiB,IAAI,MAAM,aAAa;GAE7D,MAAM,gBAAgB,KAAKA,iBAAiB,IAAI,KAAK;GACrD,IAAI,WAAW,eACb,uBAAO,IAAI,IAAI,CAAC,GAAG,SAAS,GAAG,aAAa,CAAC;GAE/C,OAAO,WAAW;EACpB;EAEA,IAAI,UAAU,MAAM,QAAQ,KAAA;EAC5B,OAAO,KAAKA,iBAAiB,IAAI,KAAK;CACxC;CAEA,IAAI,OAAY,QAAsB;EAEpC,IAAIC,eAAAA,SAAS,KAAK,KAAK,MAAM,eAC3B,KAAK,MAAM,MAAM,eAAe,MAAM;EAExC,KAAK,MAAM,OAAO,MAAM;CAC1B;CAEA,OAAO,OAAY,QAAsB;EACvC,IAAIA,eAAAA,SAAS,KAAK,KAAK,MAAM,eAC3B,KAAK,SAAS,MAAM,eAAe,MAAM;EAE3C,KAAK,SAAS,OAAO,MAAM;CAC7B;CAEA,MAAc,OAAY,QAAsB;EAC9C,MAAM,MAAM,KAAKD,iBAAiB,IAAI,KAAK,qBAAK,IAAI,IAAI;EACxD,IAAI,IAAI,SAAS,GAAG,KAAKA,iBAAiB,IAAI,OAAO,GAAG;EACxD,IAAI,IAAI,MAAM;CAChB;CAEA,SAAiB,OAAY,QAAsB;EACjD,MAAM,MAAM,KAAKA,iBAAiB,IAAI,KAAK;EAC3C,IAAI,KAAK;GACP,IAAI,OAAO,MAAM;GACjB,IAAI,IAAI,SAAS,GACf,KAAKA,iBAAiB,OAAO,KAAK;EAEtC;CACF;AACF;AAEA,SAAS,cAAiB,MAAc,MAAsB;CAC5D,MAAM,CAAC,SAAS,UAAU,KAAK,QAAQ,KAAK,OAAO,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,IAAI;CAC7E,MAAM,yBAAS,IAAI,IAAO;CAC1B,KAAK,MAAM,QAAQ,SACjB,IAAI,OAAO,IAAI,IAAI,GACjB,OAAO,IAAI,IAAI;CAGnB,OAAO;AACT"}
1
+ {"version":3,"file":"IndexManager.cjs","names":["#indexes","groupBy","getMetadata","isLoadedReference","#valueToEntities","#arrayToEntities","isEntity","fastWhereFilterHash"],"sources":["../src/IndexManager.ts"],"sourcesContent":["import { fastWhereFilterHash } from \"src/dataloaders/fastWhereFilterHash.ts\";\nimport { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityMetadata, type Field, getMetadata } from \"src/EntityMetadata.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { groupBy } from \"src/utils.ts\";\n\ntype FieldValue = any;\ntype FieldName = string;\ntype EntityTag = string;\ntype FieldIndexEntry = {\n field: Field;\n index: FieldIndex;\n /** Tracks how far into the EM's list-per-tag array this field index has been lazily populated. */\n indexedCount: number;\n};\nconst emptySet = new Set<Entity>();\n\n// The test reproducing a n^2 with n=500 went from 100ms to 50ms if indexed\nexport const indexThreshold = 500;\n\n/**\n * IndexManager provides field-based indexing for entity queries to avoid O(n) linear scans of `em.entities`.\n *\n * Key features:\n * - Only indexes entity types with >1000 entities to avoid overhead for small datasets\n * - Supports dual indexing for m2o/poly fields (by ID for saved, by instance for unsaved)\n * - Automatically maintains indexes when fields are updated via setField()\n */\nexport class IndexManager {\n readonly #indexes: Map<EntityTag, Map<FieldName, FieldIndexEntry>> = new Map();\n\n /** @return if we should index entities of this type/count. */\n shouldIndexType(entityCount: number): boolean {\n return entityCount >= indexThreshold;\n }\n\n /** Visible for testing. */\n isIndexed(tagName: string): boolean {\n return this.#indexes.has(tagName);\n }\n\n /** Enables indexing for an entity type and builds indexes for the requested fields. */\n enableIndexingForType<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: object): void {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n this.#indexes.set(tagName, new Map());\n }\n\n const fieldNames = Object.keys(where);\n const fieldIndexes = this.#indexes.get(tagName)!;\n const missingFieldNames = fieldNames.filter((fieldName) => !fieldIndexes.has(fieldName));\n if (missingFieldNames.length === 0) return;\n\n // If subtypes are involved, group by each subtype\n if (meta.baseType || meta.subTypes.length > 0) {\n [...groupBy(entities, (e) => getMetadata(e)).entries()].forEach(([meta, entities]) => {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n });\n } else {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n }\n }\n\n /** Updates indexes when a field value changes. */\n maybeUpdateFieldIndex(entity: Entity, fieldName: string, oldValue: any, newValue: any): void {\n // Fast return for the common case, which is no indexing\n if (this.#indexes.size === 0) return;\n\n const meta = getMetadata(entity);\n const fieldIndexes = this.#indexes.get(meta.tagName);\n if (!fieldIndexes) return; // Type not indexed\n\n const fieldIndex = fieldIndexes.get(fieldName)?.index;\n if (!fieldIndex) return; // Field is not indexed\n\n fieldIndex.remove(oldValue, entity);\n fieldIndex.add(newValue, entity);\n }\n\n /**\n * Finds entities matching the given where clause using indexes.\n *\n * Returns the raw candidate set (possibly a live internal index set, so do not mutate it);\n * the caller applies its own subtype/deleted filtering while building the final array.\n */\n findMatching<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: any): ReadonlySet<T> {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n throw new Error(`${meta.type} is not indexed`);\n }\n const fieldIndexes = this.#indexes.get(tagName)!;\n // Start with all entities of the 1st condition, then intersect (AND) each subsequent field in `where`\n let candidates: Set<Entity> | undefined;\n for (const [fieldName, value] of Object.entries(where)) {\n this.indexUnindexedEntities(entities, fieldName);\n const fieldCandidates = fieldIndexes.get(fieldName)?.index.get(value);\n if (!candidates) {\n candidates = fieldCandidates ?? emptySet; // This is the 1st clause\n } else {\n candidates = fieldCandidates ? intersectSets(candidates, fieldCandidates) : emptySet;\n }\n // Early exit if no candidates remain\n if (candidates.size === 0) break;\n }\n if (candidates === undefined) {\n throw new Error(`Expected where clause with at least one condition`);\n }\n return candidates as unknown as ReadonlySet<T>;\n }\n\n // `entities` should all be of the exact same subtype\n private addEntitiesToIndex(meta: EntityMetadata, entities: Entity[], fieldNames: string[]): void {\n const { tagName } = meta;\n const indexes = this.#indexes.get(tagName)!;\n // Iterate over each field so we can do a shared fieldIndex lookup\n for (const fieldName of fieldNames) {\n const field = meta.allFields[fieldName];\n if (!field) continue;\n let entry = indexes.get(fieldName);\n if (!entry) {\n entry = { field, index: new FieldIndex(meta.columns[fieldName]?.isArray ?? false), indexedCount: 0 };\n indexes.set(fieldName, entry);\n }\n for (const entity of entities) {\n // Deleted entities never match, and reading a flushed one's relations throws\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n }\n\n /** Lazily indexes newly-registered entities only for fields that are queried. */\n private indexUnindexedEntities<T extends Entity>(entities: T[], fieldName: string): void {\n const first = entities[0];\n if (first === undefined) return;\n\n const meta = getMetadata(first);\n const entry = this.#indexes.get(meta.tagName)?.get(fieldName);\n if (entry === undefined || entry.indexedCount === entities.length) return;\n\n for (let i = entry.indexedCount; i < entities.length; i++) {\n const entity = entities[i];\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n}\n\n// Use the same field access pattern as entityMatches\nfunction getFieldValue(entity: Entity, fieldName: string, field: Field): any {\n const fn = fieldName as keyof Entity;\n switch (field.kind) {\n case \"primaryKey\":\n return entity.idTaggedMaybe;\n case \"enum\":\n case \"primitive\":\n return (entity as any)[fn];\n case \"m2o\":\n case \"poly\":\n const relation = (entity as any)[fn] as\n | ManyToOneReference<Entity, any, any>\n | PolymorphicReference<Entity, any, any>;\n if (isLoadedReference(relation)) {\n return relation.get;\n } else if (relation.isSet) {\n return relation.id;\n } else {\n return undefined;\n }\n default:\n return undefined;\n }\n}\n\n/**\n * For a given field, like `firstName`, holds the reverse index of value `bob` -> entities `[a1, a2]`.\n *\n * This also handles indexes `Entity` values as both instances & IDs, to handle both:\n * - new/unpersisted entities not yet having IDs, and\n * - relations not having loaded their instances yet\n */\nclass FieldIndex {\n readonly #valueToEntities = new Map<FieldValue, Set<Entity>>();\n // Keep serialized arrays separate so their keys cannot collide with scalar string values.\n readonly #arrayToEntities = new Map<string, Set<Entity>>();\n\n constructor(private readonly isArray: boolean) {}\n\n /** @return entities that have `value` as their current value for this field. */\n get(value: any): Set<Entity> | undefined {\n if (isEntity(value) && value.idTaggedMaybe) {\n // If `value` is a persisted entity like `a:1`, and entities could have indexed their `author_id=a:1` field\n // values as the unloaded \"just an a:1 string id\" value, before `a:1` was loaded into memory.\n const matchId = this.#valueToEntities.get(value.idTaggedMaybe);\n // But other entities could also have indexes `a:1` as an instance, so go ahead and check both\n const matchInstance = this.#valueToEntities.get(value);\n if (matchId && matchInstance) {\n return new Set([...matchId, ...matchInstance]);\n }\n return matchId ?? matchInstance;\n }\n // Treat null and undefined as equivalent for unset relations\n if (value === null) value = undefined;\n const arrayKey = this.maybeArrayKey(value);\n if (arrayKey !== undefined) return this.#arrayToEntities.get(arrayKey);\n return this.#valueToEntities.get(value);\n }\n\n add(value: any, entity: Entity): void {\n // If this is an entity, store both the entity itself & its ID\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doAdd(value.idTaggedMaybe, entity);\n }\n this.doAdd(value, entity);\n }\n\n remove(value: any, entity: Entity): void {\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doRemove(value.idTaggedMaybe, entity);\n }\n this.doRemove(value, entity);\n }\n\n private doAdd(value: any, entity: Entity): void {\n const arrayKey = this.maybeArrayKey(value);\n const index = arrayKey === undefined ? this.#valueToEntities : this.#arrayToEntities;\n const key = arrayKey ?? value;\n const set = index.get(key) ?? new Set();\n if (set.size === 0) index.set(key, set);\n set.add(entity);\n }\n\n private doRemove(value: any, entity: Entity): void {\n const arrayKey = this.maybeArrayKey(value);\n const index = arrayKey === undefined ? this.#valueToEntities : this.#arrayToEntities;\n const key = arrayKey ?? value;\n const set = index.get(key);\n if (set) {\n set.delete(entity);\n if (set.size === 0) {\n index.delete(key);\n }\n }\n }\n\n /** Preserves array order and element types, including bigint values that JSON cannot encode. */\n private maybeArrayKey(value: unknown): string | undefined {\n return this.isArray && Array.isArray(value) ? fastWhereFilterHash(value) : undefined;\n }\n}\n\nfunction intersectSets<T>(set1: Set<T>, set2: Set<T>): Set<T> {\n const [smaller, larger] = set1.size <= set2.size ? [set1, set2] : [set2, set1];\n const result = new Set<T>();\n for (const item of smaller) {\n if (larger.has(item)) {\n result.add(item);\n }\n }\n return result;\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"IndexManager.d.cts","names":[],"sources":["../src/IndexManager.ts"],"mappings":";;;cAiBa;;;;;;;;;cAUA;;;EAIX,gBAAgB;;EAKhB,UAAU;;EAKV,sBAAsB,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK;;EAsBhF,sBAAsB,QAAQ,QAAQ,mBAAmB,eAAe;;;;;;;EAqBxE,aAAa,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK,aAAa,YAAY;UA0BxF;;UAuBA"}
1
+ {"version":3,"file":"IndexManager.d.cts","names":[],"sources":["../src/IndexManager.ts"],"mappings":";;;cAkBa;;;;;;;;;cAUA;;;EAIX,gBAAgB;;EAKhB,UAAU;;EAKV,sBAAsB,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK;;EAsBhF,sBAAsB,QAAQ,QAAQ,mBAAmB,eAAe;;;;;;;EAqBxE,aAAa,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK,aAAa,YAAY;UA0BxF;;UAuBA"}
@@ -1 +1 @@
1
- {"version":3,"file":"IndexManager.d.mts","names":[],"sources":["../src/IndexManager.ts"],"mappings":";;;cAiBa;;;;;;;;;cAUA;;;EAIX,gBAAgB;;EAKhB,UAAU;;EAKV,sBAAsB,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK;;EAsBhF,sBAAsB,QAAQ,QAAQ,mBAAmB,eAAe;;;;;;;EAqBxE,aAAa,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK,aAAa,YAAY;UA0BxF;;UAuBA"}
1
+ {"version":3,"file":"IndexManager.d.mts","names":[],"sources":["../src/IndexManager.ts"],"mappings":";;;cAkBa;;;;;;;;;cAUA;;;EAIX,gBAAgB;;EAKhB,UAAU;;EAKV,sBAAsB,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK;;EAsBhF,sBAAsB,QAAQ,QAAQ,mBAAmB,eAAe;;;;;;;EAqBxE,aAAa,UAAU,QAAQ,MAAM,eAAe,IAAI,UAAU,KAAK,aAAa,YAAY;UA0BxF;;UAuBA"}
@@ -2,6 +2,7 @@ import { isLoadedReference } from "./relations/Reference.js";
2
2
  import "./relations/index.js";
3
3
  import { groupBy } from "./utils.js";
4
4
  import { getMetadata } from "./EntityMetadata.js";
5
+ import { fastWhereFilterHash } from "./dataloaders/fastWhereFilterHash.js";
5
6
  import { isEntity } from "./Entity.js";
6
7
  //#region src/IndexManager.ts
7
8
  const emptySet = /* @__PURE__ */ new Set();
@@ -79,7 +80,7 @@ var IndexManager = class {
79
80
  if (!entry) {
80
81
  entry = {
81
82
  field,
82
- index: new FieldIndex(),
83
+ index: new FieldIndex(meta.columns[fieldName]?.isArray ?? false),
83
84
  indexedCount: 0
84
85
  };
85
86
  indexes.set(fieldName, entry);
@@ -131,7 +132,12 @@ function getFieldValue(entity, fieldName, field) {
131
132
  * - relations not having loaded their instances yet
132
133
  */
133
134
  var FieldIndex = class {
135
+ isArray;
134
136
  #valueToEntities = /* @__PURE__ */ new Map();
137
+ #arrayToEntities = /* @__PURE__ */ new Map();
138
+ constructor(isArray) {
139
+ this.isArray = isArray;
140
+ }
135
141
  /** @return entities that have `value` as their current value for this field. */
136
142
  get(value) {
137
143
  if (isEntity(value) && value.idTaggedMaybe) {
@@ -141,6 +147,8 @@ var FieldIndex = class {
141
147
  return matchId ?? matchInstance;
142
148
  }
143
149
  if (value === null) value = void 0;
150
+ const arrayKey = this.maybeArrayKey(value);
151
+ if (arrayKey !== void 0) return this.#arrayToEntities.get(arrayKey);
144
152
  return this.#valueToEntities.get(value);
145
153
  }
146
154
  add(value, entity) {
@@ -152,17 +160,27 @@ var FieldIndex = class {
152
160
  this.doRemove(value, entity);
153
161
  }
154
162
  doAdd(value, entity) {
155
- const set = this.#valueToEntities.get(value) ?? /* @__PURE__ */ new Set();
156
- if (set.size === 0) this.#valueToEntities.set(value, set);
163
+ const arrayKey = this.maybeArrayKey(value);
164
+ const index = arrayKey === void 0 ? this.#valueToEntities : this.#arrayToEntities;
165
+ const key = arrayKey ?? value;
166
+ const set = index.get(key) ?? /* @__PURE__ */ new Set();
167
+ if (set.size === 0) index.set(key, set);
157
168
  set.add(entity);
158
169
  }
159
170
  doRemove(value, entity) {
160
- const set = this.#valueToEntities.get(value);
171
+ const arrayKey = this.maybeArrayKey(value);
172
+ const index = arrayKey === void 0 ? this.#valueToEntities : this.#arrayToEntities;
173
+ const key = arrayKey ?? value;
174
+ const set = index.get(key);
161
175
  if (set) {
162
176
  set.delete(entity);
163
- if (set.size === 0) this.#valueToEntities.delete(value);
177
+ if (set.size === 0) index.delete(key);
164
178
  }
165
179
  }
180
+ /** Preserves array order and element types, including bigint values that JSON cannot encode. */
181
+ maybeArrayKey(value) {
182
+ return this.isArray && Array.isArray(value) ? fastWhereFilterHash(value) : void 0;
183
+ }
166
184
  };
167
185
  function intersectSets(set1, set2) {
168
186
  const [smaller, larger] = set1.size <= set2.size ? [set1, set2] : [set2, set1];
@@ -1 +1 @@
1
- {"version":3,"file":"IndexManager.js","names":["#indexes","#valueToEntities"],"sources":["../src/IndexManager.ts"],"sourcesContent":["import { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityMetadata, type Field, getMetadata } from \"src/EntityMetadata.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { groupBy } from \"src/utils.ts\";\n\ntype FieldValue = any;\ntype FieldName = string;\ntype EntityTag = string;\ntype FieldIndexEntry = {\n field: Field;\n index: FieldIndex;\n /** Tracks how far into the EM's list-per-tag array this field index has been lazily populated. */\n indexedCount: number;\n};\nconst emptySet = new Set<Entity>();\n\n// The test reproducing a n^2 with n=500 went from 100ms to 50ms if indexed\nexport const indexThreshold = 500;\n\n/**\n * IndexManager provides field-based indexing for entity queries to avoid O(n) linear scans of `em.entities`.\n *\n * Key features:\n * - Only indexes entity types with >1000 entities to avoid overhead for small datasets\n * - Supports dual indexing for m2o/poly fields (by ID for saved, by instance for unsaved)\n * - Automatically maintains indexes when fields are updated via setField()\n */\nexport class IndexManager {\n readonly #indexes: Map<EntityTag, Map<FieldName, FieldIndexEntry>> = new Map();\n\n /** @return if we should index entities of this type/count. */\n shouldIndexType(entityCount: number): boolean {\n return entityCount >= indexThreshold;\n }\n\n /** Visible for testing. */\n isIndexed(tagName: string): boolean {\n return this.#indexes.has(tagName);\n }\n\n /** Enables indexing for an entity type and builds indexes for the requested fields. */\n enableIndexingForType<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: object): void {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n this.#indexes.set(tagName, new Map());\n }\n\n const fieldNames = Object.keys(where);\n const fieldIndexes = this.#indexes.get(tagName)!;\n const missingFieldNames = fieldNames.filter((fieldName) => !fieldIndexes.has(fieldName));\n if (missingFieldNames.length === 0) return;\n\n // If subtypes are involved, group by each subtype\n if (meta.baseType || meta.subTypes.length > 0) {\n [...groupBy(entities, (e) => getMetadata(e)).entries()].forEach(([meta, entities]) => {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n });\n } else {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n }\n }\n\n /** Updates indexes when a field value changes. */\n maybeUpdateFieldIndex(entity: Entity, fieldName: string, oldValue: any, newValue: any): void {\n // Fast return for the common case, which is no indexing\n if (this.#indexes.size === 0) return;\n\n const meta = getMetadata(entity);\n const fieldIndexes = this.#indexes.get(meta.tagName);\n if (!fieldIndexes) return; // Type not indexed\n\n const fieldIndex = fieldIndexes.get(fieldName)?.index;\n if (!fieldIndex) return; // Field is not indexed\n\n fieldIndex.remove(oldValue, entity);\n fieldIndex.add(newValue, entity);\n }\n\n /**\n * Finds entities matching the given where clause using indexes.\n *\n * Returns the raw candidate set (possibly a live internal index set, so do not mutate it);\n * the caller applies its own subtype/deleted filtering while building the final array.\n */\n findMatching<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: any): ReadonlySet<T> {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n throw new Error(`${meta.type} is not indexed`);\n }\n const fieldIndexes = this.#indexes.get(tagName)!;\n // Start with all entities of the 1st condition, then intersect (AND) each subsequent field in `where`\n let candidates: Set<Entity> | undefined;\n for (const [fieldName, value] of Object.entries(where)) {\n this.indexUnindexedEntities(entities, fieldName);\n const fieldCandidates = fieldIndexes.get(fieldName)?.index.get(value);\n if (!candidates) {\n candidates = fieldCandidates ?? emptySet; // This is the 1st clause\n } else {\n candidates = fieldCandidates ? intersectSets(candidates, fieldCandidates) : emptySet;\n }\n // Early exit if no candidates remain\n if (candidates.size === 0) break;\n }\n if (candidates === undefined) {\n throw new Error(`Expected where clause with at least one condition`);\n }\n return candidates as unknown as ReadonlySet<T>;\n }\n\n // `entities` should all be of the exact same subtype\n private addEntitiesToIndex(meta: EntityMetadata, entities: Entity[], fieldNames: string[]): void {\n const { tagName } = meta;\n const indexes = this.#indexes.get(tagName)!;\n // Iterate over each field so we can do a shared fieldIndex lookup\n for (const fieldName of fieldNames) {\n const field = meta.allFields[fieldName];\n if (!field) continue;\n let entry = indexes.get(fieldName);\n if (!entry) {\n entry = { field, index: new FieldIndex(), indexedCount: 0 };\n indexes.set(fieldName, entry);\n }\n for (const entity of entities) {\n // Deleted entities never match, and reading a flushed one's relations throws\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n }\n\n /** Lazily indexes newly-registered entities only for fields that are queried. */\n private indexUnindexedEntities<T extends Entity>(entities: T[], fieldName: string): void {\n const first = entities[0];\n if (first === undefined) return;\n\n const meta = getMetadata(first);\n const entry = this.#indexes.get(meta.tagName)?.get(fieldName);\n if (entry === undefined || entry.indexedCount === entities.length) return;\n\n for (let i = entry.indexedCount; i < entities.length; i++) {\n const entity = entities[i];\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n}\n\n// Use the same field access pattern as entityMatches\nfunction getFieldValue(entity: Entity, fieldName: string, field: Field): any {\n const fn = fieldName as keyof Entity;\n switch (field.kind) {\n case \"primaryKey\":\n return entity.idTaggedMaybe;\n case \"enum\":\n case \"primitive\":\n return (entity as any)[fn];\n case \"m2o\":\n case \"poly\":\n const relation = (entity as any)[fn] as\n | ManyToOneReference<Entity, any, any>\n | PolymorphicReference<Entity, any, any>;\n if (isLoadedReference(relation)) {\n return relation.get;\n } else if (relation.isSet) {\n return relation.id;\n } else {\n return undefined;\n }\n default:\n return undefined;\n }\n}\n\n/**\n * For a given field, like `firstName`, holds the reverse index of value `bob` -> entities `[a1, a2]`.\n *\n * This also handles indexes `Entity` values as both instances & IDs, to handle both:\n * - new/unpersisted entities not yet having IDs, and\n * - relations not having loaded their instances yet\n */\nclass FieldIndex {\n readonly #valueToEntities = new Map<FieldValue, Set<Entity>>();\n\n /** @return entities that have `value` as their current value for this field. */\n get(value: any): Set<Entity> | undefined {\n if (isEntity(value) && value.idTaggedMaybe) {\n // If `value` is a persisted entity like `a:1`, and entities could have indexed their `author_id=a:1` field\n // values as the unloaded \"just an a:1 string id\" value, before `a:1` was loaded into memory.\n const matchId = this.#valueToEntities.get(value.idTaggedMaybe);\n // But other entities could also have indexes `a:1` as an instance, so go ahead and check both\n const matchInstance = this.#valueToEntities.get(value);\n if (matchId && matchInstance) {\n return new Set([...matchId, ...matchInstance]);\n }\n return matchId ?? matchInstance;\n }\n // Treat null and undefined as equivalent for unset relations\n if (value === null) value = undefined;\n return this.#valueToEntities.get(value);\n }\n\n add(value: any, entity: Entity): void {\n // If this is an entity, store both the entity itself & its ID\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doAdd(value.idTaggedMaybe, entity);\n }\n this.doAdd(value, entity);\n }\n\n remove(value: any, entity: Entity): void {\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doRemove(value.idTaggedMaybe, entity);\n }\n this.doRemove(value, entity);\n }\n\n private doAdd(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value) ?? new Set();\n if (set.size === 0) this.#valueToEntities.set(value, set);\n set.add(entity);\n }\n\n private doRemove(value: any, entity: Entity): void {\n const set = this.#valueToEntities.get(value);\n if (set) {\n set.delete(entity);\n if (set.size === 0) {\n this.#valueToEntities.delete(value);\n }\n }\n }\n}\n\nfunction intersectSets<T>(set1: Set<T>, set2: Set<T>): Set<T> {\n const [smaller, larger] = set1.size <= set2.size ? [set1, set2] : [set2, set1];\n const result = new Set<T>();\n for (const item of smaller) {\n if (larger.has(item)) {\n result.add(item);\n }\n }\n return result;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"IndexManager.js","names":["#indexes","#valueToEntities","#arrayToEntities"],"sources":["../src/IndexManager.ts"],"sourcesContent":["import { fastWhereFilterHash } from \"src/dataloaders/fastWhereFilterHash.ts\";\nimport { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityMetadata, type Field, getMetadata } from \"src/EntityMetadata.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { groupBy } from \"src/utils.ts\";\n\ntype FieldValue = any;\ntype FieldName = string;\ntype EntityTag = string;\ntype FieldIndexEntry = {\n field: Field;\n index: FieldIndex;\n /** Tracks how far into the EM's list-per-tag array this field index has been lazily populated. */\n indexedCount: number;\n};\nconst emptySet = new Set<Entity>();\n\n// The test reproducing a n^2 with n=500 went from 100ms to 50ms if indexed\nexport const indexThreshold = 500;\n\n/**\n * IndexManager provides field-based indexing for entity queries to avoid O(n) linear scans of `em.entities`.\n *\n * Key features:\n * - Only indexes entity types with >1000 entities to avoid overhead for small datasets\n * - Supports dual indexing for m2o/poly fields (by ID for saved, by instance for unsaved)\n * - Automatically maintains indexes when fields are updated via setField()\n */\nexport class IndexManager {\n readonly #indexes: Map<EntityTag, Map<FieldName, FieldIndexEntry>> = new Map();\n\n /** @return if we should index entities of this type/count. */\n shouldIndexType(entityCount: number): boolean {\n return entityCount >= indexThreshold;\n }\n\n /** Visible for testing. */\n isIndexed(tagName: string): boolean {\n return this.#indexes.has(tagName);\n }\n\n /** Enables indexing for an entity type and builds indexes for the requested fields. */\n enableIndexingForType<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: object): void {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n this.#indexes.set(tagName, new Map());\n }\n\n const fieldNames = Object.keys(where);\n const fieldIndexes = this.#indexes.get(tagName)!;\n const missingFieldNames = fieldNames.filter((fieldName) => !fieldIndexes.has(fieldName));\n if (missingFieldNames.length === 0) return;\n\n // If subtypes are involved, group by each subtype\n if (meta.baseType || meta.subTypes.length > 0) {\n [...groupBy(entities, (e) => getMetadata(e)).entries()].forEach(([meta, entities]) => {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n });\n } else {\n this.addEntitiesToIndex(meta, entities, missingFieldNames);\n }\n }\n\n /** Updates indexes when a field value changes. */\n maybeUpdateFieldIndex(entity: Entity, fieldName: string, oldValue: any, newValue: any): void {\n // Fast return for the common case, which is no indexing\n if (this.#indexes.size === 0) return;\n\n const meta = getMetadata(entity);\n const fieldIndexes = this.#indexes.get(meta.tagName);\n if (!fieldIndexes) return; // Type not indexed\n\n const fieldIndex = fieldIndexes.get(fieldName)?.index;\n if (!fieldIndex) return; // Field is not indexed\n\n fieldIndex.remove(oldValue, entity);\n fieldIndex.add(newValue, entity);\n }\n\n /**\n * Finds entities matching the given where clause using indexes.\n *\n * Returns the raw candidate set (possibly a live internal index set, so do not mutate it);\n * the caller applies its own subtype/deleted filtering while building the final array.\n */\n findMatching<T extends Entity>(meta: EntityMetadata<T>, entities: T[], where: any): ReadonlySet<T> {\n const { tagName } = meta;\n if (!this.#indexes.has(tagName)) {\n throw new Error(`${meta.type} is not indexed`);\n }\n const fieldIndexes = this.#indexes.get(tagName)!;\n // Start with all entities of the 1st condition, then intersect (AND) each subsequent field in `where`\n let candidates: Set<Entity> | undefined;\n for (const [fieldName, value] of Object.entries(where)) {\n this.indexUnindexedEntities(entities, fieldName);\n const fieldCandidates = fieldIndexes.get(fieldName)?.index.get(value);\n if (!candidates) {\n candidates = fieldCandidates ?? emptySet; // This is the 1st clause\n } else {\n candidates = fieldCandidates ? intersectSets(candidates, fieldCandidates) : emptySet;\n }\n // Early exit if no candidates remain\n if (candidates.size === 0) break;\n }\n if (candidates === undefined) {\n throw new Error(`Expected where clause with at least one condition`);\n }\n return candidates as unknown as ReadonlySet<T>;\n }\n\n // `entities` should all be of the exact same subtype\n private addEntitiesToIndex(meta: EntityMetadata, entities: Entity[], fieldNames: string[]): void {\n const { tagName } = meta;\n const indexes = this.#indexes.get(tagName)!;\n // Iterate over each field so we can do a shared fieldIndex lookup\n for (const fieldName of fieldNames) {\n const field = meta.allFields[fieldName];\n if (!field) continue;\n let entry = indexes.get(fieldName);\n if (!entry) {\n entry = { field, index: new FieldIndex(meta.columns[fieldName]?.isArray ?? false), indexedCount: 0 };\n indexes.set(fieldName, entry);\n }\n for (const entity of entities) {\n // Deleted entities never match, and reading a flushed one's relations throws\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n }\n\n /** Lazily indexes newly-registered entities only for fields that are queried. */\n private indexUnindexedEntities<T extends Entity>(entities: T[], fieldName: string): void {\n const first = entities[0];\n if (first === undefined) return;\n\n const meta = getMetadata(first);\n const entry = this.#indexes.get(meta.tagName)?.get(fieldName);\n if (entry === undefined || entry.indexedCount === entities.length) return;\n\n for (let i = entry.indexedCount; i < entities.length; i++) {\n const entity = entities[i];\n if (entity.isDeletedEntity) continue;\n const value = getFieldValue(entity, fieldName, entry.field);\n entry.index.add(value, entity);\n }\n entry.indexedCount = entities.length;\n }\n}\n\n// Use the same field access pattern as entityMatches\nfunction getFieldValue(entity: Entity, fieldName: string, field: Field): any {\n const fn = fieldName as keyof Entity;\n switch (field.kind) {\n case \"primaryKey\":\n return entity.idTaggedMaybe;\n case \"enum\":\n case \"primitive\":\n return (entity as any)[fn];\n case \"m2o\":\n case \"poly\":\n const relation = (entity as any)[fn] as\n | ManyToOneReference<Entity, any, any>\n | PolymorphicReference<Entity, any, any>;\n if (isLoadedReference(relation)) {\n return relation.get;\n } else if (relation.isSet) {\n return relation.id;\n } else {\n return undefined;\n }\n default:\n return undefined;\n }\n}\n\n/**\n * For a given field, like `firstName`, holds the reverse index of value `bob` -> entities `[a1, a2]`.\n *\n * This also handles indexes `Entity` values as both instances & IDs, to handle both:\n * - new/unpersisted entities not yet having IDs, and\n * - relations not having loaded their instances yet\n */\nclass FieldIndex {\n readonly #valueToEntities = new Map<FieldValue, Set<Entity>>();\n // Keep serialized arrays separate so their keys cannot collide with scalar string values.\n readonly #arrayToEntities = new Map<string, Set<Entity>>();\n\n constructor(private readonly isArray: boolean) {}\n\n /** @return entities that have `value` as their current value for this field. */\n get(value: any): Set<Entity> | undefined {\n if (isEntity(value) && value.idTaggedMaybe) {\n // If `value` is a persisted entity like `a:1`, and entities could have indexed their `author_id=a:1` field\n // values as the unloaded \"just an a:1 string id\" value, before `a:1` was loaded into memory.\n const matchId = this.#valueToEntities.get(value.idTaggedMaybe);\n // But other entities could also have indexes `a:1` as an instance, so go ahead and check both\n const matchInstance = this.#valueToEntities.get(value);\n if (matchId && matchInstance) {\n return new Set([...matchId, ...matchInstance]);\n }\n return matchId ?? matchInstance;\n }\n // Treat null and undefined as equivalent for unset relations\n if (value === null) value = undefined;\n const arrayKey = this.maybeArrayKey(value);\n if (arrayKey !== undefined) return this.#arrayToEntities.get(arrayKey);\n return this.#valueToEntities.get(value);\n }\n\n add(value: any, entity: Entity): void {\n // If this is an entity, store both the entity itself & its ID\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doAdd(value.idTaggedMaybe, entity);\n }\n this.doAdd(value, entity);\n }\n\n remove(value: any, entity: Entity): void {\n if (isEntity(value) && value.idTaggedMaybe) {\n this.doRemove(value.idTaggedMaybe, entity);\n }\n this.doRemove(value, entity);\n }\n\n private doAdd(value: any, entity: Entity): void {\n const arrayKey = this.maybeArrayKey(value);\n const index = arrayKey === undefined ? this.#valueToEntities : this.#arrayToEntities;\n const key = arrayKey ?? value;\n const set = index.get(key) ?? new Set();\n if (set.size === 0) index.set(key, set);\n set.add(entity);\n }\n\n private doRemove(value: any, entity: Entity): void {\n const arrayKey = this.maybeArrayKey(value);\n const index = arrayKey === undefined ? this.#valueToEntities : this.#arrayToEntities;\n const key = arrayKey ?? value;\n const set = index.get(key);\n if (set) {\n set.delete(entity);\n if (set.size === 0) {\n index.delete(key);\n }\n }\n }\n\n /** Preserves array order and element types, including bigint values that JSON cannot encode. */\n private maybeArrayKey(value: unknown): string | undefined {\n return this.isArray && Array.isArray(value) ? fastWhereFilterHash(value) : undefined;\n }\n}\n\nfunction intersectSets<T>(set1: Set<T>, set2: Set<T>): Set<T> {\n const [smaller, larger] = set1.size <= set2.size ? [set1, set2] : [set2, set1];\n const result = new Set<T>();\n for (const item of smaller) {\n if (larger.has(item)) {\n result.add(item);\n }\n }\n return result;\n}\n"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_fields = require("../fields.cjs");
2
3
  const require_relations_Reference = require("../relations/Reference.cjs");
3
4
  require("../relations/index.cjs");
4
5
  const require_utils = require("../utils.cjs");
@@ -38,7 +39,7 @@ function findOrCreateDataLoader(em, type, where, softDeletes) {
38
39
  if (upsert) entity.set(upsert);
39
40
  return keys.map(() => entity);
40
41
  }
41
- const entities = await em.find(type, Object.fromEntries(Object.entries(where).map(([k, v]) => [k, v === void 0 ? null : v])), { softDeletes: "include" });
42
+ const entities = await em.find(type, require_resurrection.newWhereEqual(meta, where), { softDeletes: "include" });
42
43
  let entity;
43
44
  if (entities.length > 1) throw new require_EntityManager.TooManyError(`Found more than one existing ${type.name} with ${whereAsString(where)}`);
44
45
  else if (entities.length === 1) {
@@ -61,7 +62,10 @@ function entityMatches(entity, opts) {
61
62
  switch (field.kind) {
62
63
  case "primaryKey":
63
64
  case "enum": return entity[fn] === value;
64
- case "primitive": if (field.citext) return compareCaseInsensitive(entity[fn], value);
65
+ case "primitive": if (meta.columns[fieldName]?.isArray) {
66
+ const current = entity[fn];
67
+ return Array.isArray(current) && Array.isArray(value) ? require_fields.equalArrays(current, value) : current === value;
68
+ } else if (field.citext) return compareCaseInsensitive(entity[fn], value);
65
69
  else return entity[fn] === value;
66
70
  case "m2o":
67
71
  case "poly":
@@ -1 +1 @@
1
- {"version":3,"file":"findOrCreateDataLoader.cjs","names":["getMetadata","whereFilterHash","fail","TooManyError","isEntity","isLoadedReference","sameEntity","cleanStringValue"],"sources":["../../src/dataloaders/findOrCreateDataLoader.ts"],"sourcesContent":["import type DataLoader from \"dataloader\";\nimport { whereFilterHash } from \"src/dataloaders/findDataLoader.ts\";\nimport { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityConstructor, type EntityManager, TooManyError, sameEntity } from \"src/EntityManager.ts\";\nimport { type EntityMetadata, getMetadata } from \"src/EntityMetadata.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { resurrectIfSoftDeleted } from \"src/resurrection.ts\";\nimport type { OptsOf } from \"src/typeMap.ts\";\nimport { cleanStringValue, fail } from \"src/utils.ts\";\n\ninterface FindOrCreateKey<T extends Entity> {\n ifNew: OptsOf<T>;\n upsert: Partial<OptsOf<T>> | undefined;\n}\n\nexport function findOrCreateDataLoader<T extends Entity>(\n em: EntityManager,\n type: EntityConstructor<T>,\n where: Partial<OptsOf<T>>,\n softDeletes: \"include\" | \"exclude\",\n): DataLoader<FindOrCreateKey<T>, T> {\n const meta = getMetadata(type);\n\n // Do some extra logic to make unclean strings/citext not create duplicate entities\n const hasAnyStrings = Object.values(meta.allFields).some((f) => f.kind === \"primitive\" && f.type === \"string\");\n const hasAnyCitext = Object.values(meta.allFields).some((f) => f.kind === \"primitive\" && f.citext);\n\n // The `where` here will be later used to `em.create`, so we only clean (not lower for citext)\n where = hasAnyStrings ? maybeClean(meta, where) : where;\n // Use `whereFilterHash` to batch the same `findOrCreate` `where: { firstName: \"a1\" }` calls together to avoid duplicates\n const whereValue = whereFilterHash(\n // Only maybeLower for the purposes of batching, not the later em.create\n hasAnyCitext ? maybeLower(meta, where) : (where as any),\n );\n const batchKey = `${type.name}-${whereValue}-${softDeletes}`;\n\n const invalidKeys = Object.keys(where).filter((key) => {\n const field = meta.allFields[key] ?? fail(`Invalid field ${key}`);\n const supported =\n field.kind === \"primitive\" || field.kind === \"enum\" || field.kind === \"m2o\" || field.kind === \"poly\";\n return !supported;\n });\n if (invalidKeys.length > 0) {\n throw new Error(\n \"findOrCreate only supports primitive, enum, m2o, or poly fields in the where clause. Invalid keys: \" +\n invalidKeys.join(\", \"),\n );\n }\n\n return em.getLoader<FindOrCreateKey<T>, T>(\n \"find-or-create\",\n batchKey,\n async (keys) => {\n // Because our `batchKey` is based only on the `where` clause, if the user called\n // `findOrCreate({ firstName: a1 })` multiple times, but with different ifNew/upsert\n // conditions (or different cased strings and is using a citext column), we'll end\n // up with multiple keys...\n //\n // This is fundamentally asking to create the same entity, but with different ifNew/upsert\n // conditions, which we could fail on, but for now just take the first tuple of\n // {where/ifNew/upsert} and assume it wins\n const [{ ifNew, upsert }] = keys;\n\n // Before we find/create an entity, see if we have a maybe-new one in the EM already.\n // This will also use any WIP changes we've made to the found entity, which ideally is\n // something `em.find` would do as well, but its queries are much more complex...\n const inMemory = em.filterEntities(type, where);\n if (inMemory.length > 1) {\n throw new TooManyError(`Found more than one existing ${type.name} with ${whereAsString(where)}`);\n } else if (inMemory.length === 1) {\n const entity = inMemory[0] as T;\n resurrectIfSoftDeleted(entity);\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n }\n\n // If there is a param like `{ publisher: newPublisherEntity }`, then we know that\n // an entity matching this condition can't be in the db anyway, so skip the em.find\n const hasNewEntityParam = Object.values(where).some((v) => isEntity(v) && v.isNewEntity);\n if (hasNewEntityParam) {\n const entity = em.create(type, { ...where, ...(ifNew as object) } as OptsOf<T>);\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n }\n\n // If we didn't find it in the EM, do the db query/em.create\n const entities = await em.find(\n type,\n // Convert `publisher: undefined` --> `publisher: null`, and we need to make a copy anyway\n Object.fromEntries(Object.entries(where).map(([k, v]) => [k, v === undefined ? null : v])) as any,\n // Always include soft-deleted rows so findOrCreate can resurrect them instead of creating duplicates.\n { softDeletes: \"include\" },\n );\n let entity: T;\n if (entities.length > 1) {\n throw new TooManyError(`Found more than one existing ${type.name} with ${whereAsString(where)}`);\n } else if (entities.length === 1) {\n entity = entities[0];\n resurrectIfSoftDeleted(entity);\n } else {\n entity = em.create(type, { ...where, ...(ifNew as object) } as OptsOf<T>);\n }\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n },\n // Our filter tuple is a complex object, so use a stable cache key to ensure caching works.\n { cacheKeyFn: whereFilterHash as any },\n );\n}\n\n/** A simple in-memory match of `entity` against a mini/flat \"WHERE clause\" of `opts`. */\nexport function entityMatches<T extends Entity>(entity: T, opts: Partial<OptsOf<T>>): boolean {\n const meta = getMetadata(entity);\n return Object.entries(opts).every(([fieldName, value]) => {\n const field = meta.allFields[fieldName] ?? fail(`Invalid field ${fieldName}`);\n const fn = fieldName as keyof T & keyof OptsOf<T>;\n switch (field.kind) {\n case \"primaryKey\":\n case \"enum\":\n return entity[fn] === value;\n case \"primitive\":\n if (field.citext) {\n return compareCaseInsensitive(entity[fn], value);\n } else {\n return entity[fn] === value;\n }\n case \"m2o\":\n case \"poly\":\n const relation = entity[fn] as ManyToOneReference<T, any, any> | PolymorphicReference<T, any, any>;\n if (isLoadedReference(relation)) {\n // Prefer using `.get` because it will handle new/id-less entities\n return sameEntity(relation.get, value as any);\n } else if (relation.isSet) {\n // Otherwise use ids\n return sameEntity(relation.id as any, value as any);\n } else {\n // Relation is not set, match if value is null-ish (null or undefined)\n return value === undefined || value === null;\n }\n default:\n throw new Error(`Unsupported field ${fieldName}`);\n }\n });\n}\n\nfunction whereAsString(where: object): string {\n return Object.entries(where)\n .map(([key, value]) => `${key}=${value}`)\n .join(\", \");\n}\n\nfunction compareCaseInsensitive(str1: any, str2: any): boolean {\n return (\n str1 !== undefined &&\n str2 !== undefined &&\n typeof str1 === \"string\" &&\n typeof str2 === \"string\" &&\n str1.toLowerCase() === str2.toLowerCase()\n );\n}\n\n/** Returns a copy of `opts` with any `citext` values turned to lower case. */\nfunction maybeLower<T extends object | undefined>(meta: EntityMetadata, opts: T): T {\n if (!opts) return opts;\n let result: T | undefined = undefined;\n for (const k in opts) {\n const v = (opts as any)[k];\n const field = meta.allFields[k];\n const isString = field && field.kind === \"primitive\" && field.type === \"string\";\n if (isString && field.citext) {\n const lowered = v?.toLowerCase();\n if (lowered !== v) {\n result ??= { ...opts };\n (result as any)[k] = lowered as any;\n }\n }\n }\n return result ?? opts;\n}\n\n/** Returns a copy of `opts` with any string values trimmed/coalesced. */\nfunction maybeClean<T extends object | undefined>(meta: EntityMetadata, opts: T): T {\n if (!opts) return opts;\n let result: T | undefined = undefined;\n for (const k in opts) {\n const v = (opts as any)[k];\n const field = meta.allFields[k];\n const isString = field && field.kind === \"primitive\" && field.type === \"string\";\n if (isString && field.sanitize !== false) {\n const cleaned = cleanStringValue(v);\n if (cleaned !== v) {\n result ??= { ...opts }; // Only create copy on first change\n (result as any)[k] = cleaned;\n }\n }\n }\n return result ?? opts;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"findOrCreateDataLoader.cjs","names":["getMetadata","whereFilterHash","fail","TooManyError","isEntity","newWhereEqual","equalArrays","isLoadedReference","sameEntity","cleanStringValue"],"sources":["../../src/dataloaders/findOrCreateDataLoader.ts"],"sourcesContent":["import type DataLoader from \"dataloader\";\nimport { whereFilterHash } from \"src/dataloaders/findDataLoader.ts\";\nimport { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityConstructor, type EntityManager, TooManyError, sameEntity } from \"src/EntityManager.ts\";\nimport { type EntityMetadata, getMetadata } from \"src/EntityMetadata.ts\";\nimport { equalArrays } from \"src/fields.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { newWhereEqual, resurrectIfSoftDeleted } from \"src/resurrection.ts\";\nimport type { OptsOf } from \"src/typeMap.ts\";\nimport { cleanStringValue, fail } from \"src/utils.ts\";\n\ninterface FindOrCreateKey<T extends Entity> {\n ifNew: OptsOf<T>;\n upsert: Partial<OptsOf<T>> | undefined;\n}\n\nexport function findOrCreateDataLoader<T extends Entity>(\n em: EntityManager,\n type: EntityConstructor<T>,\n where: Partial<OptsOf<T>>,\n softDeletes: \"include\" | \"exclude\",\n): DataLoader<FindOrCreateKey<T>, T> {\n const meta = getMetadata(type);\n\n // Do some extra logic to make unclean strings/citext not create duplicate entities\n const hasAnyStrings = Object.values(meta.allFields).some((f) => f.kind === \"primitive\" && f.type === \"string\");\n const hasAnyCitext = Object.values(meta.allFields).some((f) => f.kind === \"primitive\" && f.citext);\n\n // The `where` here will be later used to `em.create`, so we only clean (not lower for citext)\n where = hasAnyStrings ? maybeClean(meta, where) : where;\n // Use `whereFilterHash` to batch the same `findOrCreate` `where: { firstName: \"a1\" }` calls together to avoid duplicates\n const whereValue = whereFilterHash(\n // Only maybeLower for the purposes of batching, not the later em.create\n hasAnyCitext ? maybeLower(meta, where) : (where as any),\n );\n const batchKey = `${type.name}-${whereValue}-${softDeletes}`;\n\n const invalidKeys = Object.keys(where).filter((key) => {\n const field = meta.allFields[key] ?? fail(`Invalid field ${key}`);\n const supported =\n field.kind === \"primitive\" || field.kind === \"enum\" || field.kind === \"m2o\" || field.kind === \"poly\";\n return !supported;\n });\n if (invalidKeys.length > 0) {\n throw new Error(\n \"findOrCreate only supports primitive, enum, m2o, or poly fields in the where clause. Invalid keys: \" +\n invalidKeys.join(\", \"),\n );\n }\n\n return em.getLoader<FindOrCreateKey<T>, T>(\n \"find-or-create\",\n batchKey,\n async (keys) => {\n // Because our `batchKey` is based only on the `where` clause, if the user called\n // `findOrCreate({ firstName: a1 })` multiple times, but with different ifNew/upsert\n // conditions (or different cased strings and is using a citext column), we'll end\n // up with multiple keys...\n //\n // This is fundamentally asking to create the same entity, but with different ifNew/upsert\n // conditions, which we could fail on, but for now just take the first tuple of\n // {where/ifNew/upsert} and assume it wins\n const [{ ifNew, upsert }] = keys;\n\n // Before we find/create an entity, see if we have a maybe-new one in the EM already.\n // This will also use any WIP changes we've made to the found entity, which ideally is\n // something `em.find` would do as well, but its queries are much more complex...\n const inMemory = em.filterEntities(type, where);\n if (inMemory.length > 1) {\n throw new TooManyError(`Found more than one existing ${type.name} with ${whereAsString(where)}`);\n } else if (inMemory.length === 1) {\n const entity = inMemory[0] as T;\n resurrectIfSoftDeleted(entity);\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n }\n\n // If there is a param like `{ publisher: newPublisherEntity }`, then we know that\n // an entity matching this condition can't be in the db anyway, so skip the em.find\n const hasNewEntityParam = Object.values(where).some((v) => isEntity(v) && v.isNewEntity);\n if (hasNewEntityParam) {\n const entity = em.create(type, { ...where, ...(ifNew as object) } as OptsOf<T>);\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n }\n\n // If we didn't find it in the EM, do the db query/em.create\n const entities = await em.find(\n type,\n newWhereEqual(meta, where) as any,\n // Always include soft-deleted rows so findOrCreate can resurrect them instead of creating duplicates.\n { softDeletes: \"include\" },\n );\n let entity: T;\n if (entities.length > 1) {\n throw new TooManyError(`Found more than one existing ${type.name} with ${whereAsString(where)}`);\n } else if (entities.length === 1) {\n entity = entities[0];\n resurrectIfSoftDeleted(entity);\n } else {\n entity = em.create(type, { ...where, ...(ifNew as object) } as OptsOf<T>);\n }\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n },\n // Our filter tuple is a complex object, so use a stable cache key to ensure caching works.\n { cacheKeyFn: whereFilterHash as any },\n );\n}\n\n/** A simple in-memory match of `entity` against a mini/flat \"WHERE clause\" of `opts`. */\nexport function entityMatches<T extends Entity>(entity: T, opts: Partial<OptsOf<T>>): boolean {\n const meta = getMetadata(entity);\n return Object.entries(opts).every(([fieldName, value]) => {\n const field = meta.allFields[fieldName] ?? fail(`Invalid field ${fieldName}`);\n const fn = fieldName as keyof T & keyof OptsOf<T>;\n switch (field.kind) {\n case \"primaryKey\":\n case \"enum\":\n return entity[fn] === value;\n case \"primitive\":\n if (meta.columns[fieldName]?.isArray) {\n const current = entity[fn];\n return Array.isArray(current) && Array.isArray(value) ? equalArrays(current, value) : current === value;\n } else if (field.citext) {\n return compareCaseInsensitive(entity[fn], value);\n } else {\n return entity[fn] === value;\n }\n case \"m2o\":\n case \"poly\":\n const relation = entity[fn] as ManyToOneReference<T, any, any> | PolymorphicReference<T, any, any>;\n if (isLoadedReference(relation)) {\n // Prefer using `.get` because it will handle new/id-less entities\n return sameEntity(relation.get, value as any);\n } else if (relation.isSet) {\n // Otherwise use ids\n return sameEntity(relation.id as any, value as any);\n } else {\n // Relation is not set, match if value is null-ish (null or undefined)\n return value === undefined || value === null;\n }\n default:\n throw new Error(`Unsupported field ${fieldName}`);\n }\n });\n}\n\nfunction whereAsString(where: object): string {\n return Object.entries(where)\n .map(([key, value]) => `${key}=${value}`)\n .join(\", \");\n}\n\nfunction compareCaseInsensitive(str1: any, str2: any): boolean {\n return (\n str1 !== undefined &&\n str2 !== undefined &&\n typeof str1 === \"string\" &&\n typeof str2 === \"string\" &&\n str1.toLowerCase() === str2.toLowerCase()\n );\n}\n\n/** Returns a copy of `opts` with any `citext` values turned to lower case. */\nfunction maybeLower<T extends object | undefined>(meta: EntityMetadata, opts: T): T {\n if (!opts) return opts;\n let result: T | undefined = undefined;\n for (const k in opts) {\n const v = (opts as any)[k];\n const field = meta.allFields[k];\n const isString = field && field.kind === \"primitive\" && field.type === \"string\";\n if (isString && field.citext) {\n const lowered = v?.toLowerCase();\n if (lowered !== v) {\n result ??= { ...opts };\n (result as any)[k] = lowered as any;\n }\n }\n }\n return result ?? opts;\n}\n\n/** Returns a copy of `opts` with any string values trimmed/coalesced. */\nfunction maybeClean<T extends object | undefined>(meta: EntityMetadata, opts: T): T {\n if (!opts) return opts;\n let result: T | undefined = undefined;\n for (const k in opts) {\n const v = (opts as any)[k];\n const field = meta.allFields[k];\n const isString = field && field.kind === \"primitive\" && field.type === \"string\";\n if (isString && field.sanitize !== false) {\n const cleaned = cleanStringValue(v);\n if (cleaned !== v) {\n result ??= { ...opts }; // Only create copy on first change\n (result as any)[k] = cleaned;\n }\n }\n }\n return result ?? opts;\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"findOrCreateDataLoader.d.cts","names":[],"sources":["../../src/dataloaders/findOrCreateDataLoader.ts"],"mappings":";;;;;UAUU,gBAAgB,UAAU;EAClC,OAAO,OAAO;EACd,QAAQ,QAAQ,OAAO;;iBAGT,uBAAuB,UAAU,QAC/C,IAAI,eACJ,MAAM,kBAAkB,IACxB,OAAO,QAAQ,OAAO,KACtB,qCACC,WAAW,gBAAgB,IAAI;;iBAiGlB,cAAc,UAAU,QAAQ,QAAQ,GAAG,MAAM,QAAQ,OAAO"}
1
+ {"version":3,"file":"findOrCreateDataLoader.d.cts","names":[],"sources":["../../src/dataloaders/findOrCreateDataLoader.ts"],"mappings":";;;;;UAWU,gBAAgB,UAAU;EAClC,OAAO,OAAO;EACd,QAAQ,QAAQ,OAAO;;iBAGT,uBAAuB,UAAU,QAC/C,IAAI,eACJ,MAAM,kBAAkB,IACxB,OAAO,QAAQ,OAAO,KACtB,qCACC,WAAW,gBAAgB,IAAI;;iBAgGlB,cAAc,UAAU,QAAQ,QAAQ,GAAG,MAAM,QAAQ,OAAO"}
@@ -1 +1 @@
1
- {"version":3,"file":"findOrCreateDataLoader.d.mts","names":[],"sources":["../../src/dataloaders/findOrCreateDataLoader.ts"],"mappings":";;;;;UAUU,gBAAgB,UAAU;EAClC,OAAO,OAAO;EACd,QAAQ,QAAQ,OAAO;;iBAGT,uBAAuB,UAAU,QAC/C,IAAI,eACJ,MAAM,kBAAkB,IACxB,OAAO,QAAQ,OAAO,KACtB,qCACC,WAAW,gBAAgB,IAAI;;iBAiGlB,cAAc,UAAU,QAAQ,QAAQ,GAAG,MAAM,QAAQ,OAAO"}
1
+ {"version":3,"file":"findOrCreateDataLoader.d.mts","names":[],"sources":["../../src/dataloaders/findOrCreateDataLoader.ts"],"mappings":";;;;;UAWU,gBAAgB,UAAU;EAClC,OAAO,OAAO;EACd,QAAQ,QAAQ,OAAO;;iBAGT,uBAAuB,UAAU,QAC/C,IAAI,eACJ,MAAM,kBAAkB,IACxB,OAAO,QAAQ,OAAO,KACtB,qCACC,WAAW,gBAAgB,IAAI;;iBAgGlB,cAAc,UAAU,QAAQ,QAAQ,GAAG,MAAM,QAAQ,OAAO"}
@@ -1,9 +1,10 @@
1
+ import { equalArrays } from "../fields.js";
1
2
  import { isLoadedReference } from "../relations/Reference.js";
2
3
  import "../relations/index.js";
3
4
  import { cleanStringValue, fail } from "../utils.js";
4
5
  import { getMetadata } from "../EntityMetadata.js";
5
6
  import { whereFilterHash } from "./findDataLoader.js";
6
- import { resurrectIfSoftDeleted } from "../resurrection.js";
7
+ import { newWhereEqual, resurrectIfSoftDeleted } from "../resurrection.js";
7
8
  import { TooManyError, sameEntity } from "../EntityManager.js";
8
9
  import { isEntity } from "../Entity.js";
9
10
  //#region src/dataloaders/findOrCreateDataLoader.ts
@@ -37,7 +38,7 @@ function findOrCreateDataLoader(em, type, where, softDeletes) {
37
38
  if (upsert) entity.set(upsert);
38
39
  return keys.map(() => entity);
39
40
  }
40
- const entities = await em.find(type, Object.fromEntries(Object.entries(where).map(([k, v]) => [k, v === void 0 ? null : v])), { softDeletes: "include" });
41
+ const entities = await em.find(type, newWhereEqual(meta, where), { softDeletes: "include" });
41
42
  let entity;
42
43
  if (entities.length > 1) throw new TooManyError(`Found more than one existing ${type.name} with ${whereAsString(where)}`);
43
44
  else if (entities.length === 1) {
@@ -60,7 +61,10 @@ function entityMatches(entity, opts) {
60
61
  switch (field.kind) {
61
62
  case "primaryKey":
62
63
  case "enum": return entity[fn] === value;
63
- case "primitive": if (field.citext) return compareCaseInsensitive(entity[fn], value);
64
+ case "primitive": if (meta.columns[fieldName]?.isArray) {
65
+ const current = entity[fn];
66
+ return Array.isArray(current) && Array.isArray(value) ? equalArrays(current, value) : current === value;
67
+ } else if (field.citext) return compareCaseInsensitive(entity[fn], value);
64
68
  else return entity[fn] === value;
65
69
  case "m2o":
66
70
  case "poly":
@@ -1 +1 @@
1
- {"version":3,"file":"findOrCreateDataLoader.js","names":[],"sources":["../../src/dataloaders/findOrCreateDataLoader.ts"],"sourcesContent":["import type DataLoader from \"dataloader\";\nimport { whereFilterHash } from \"src/dataloaders/findDataLoader.ts\";\nimport { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityConstructor, type EntityManager, TooManyError, sameEntity } from \"src/EntityManager.ts\";\nimport { type EntityMetadata, getMetadata } from \"src/EntityMetadata.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { resurrectIfSoftDeleted } from \"src/resurrection.ts\";\nimport type { OptsOf } from \"src/typeMap.ts\";\nimport { cleanStringValue, fail } from \"src/utils.ts\";\n\ninterface FindOrCreateKey<T extends Entity> {\n ifNew: OptsOf<T>;\n upsert: Partial<OptsOf<T>> | undefined;\n}\n\nexport function findOrCreateDataLoader<T extends Entity>(\n em: EntityManager,\n type: EntityConstructor<T>,\n where: Partial<OptsOf<T>>,\n softDeletes: \"include\" | \"exclude\",\n): DataLoader<FindOrCreateKey<T>, T> {\n const meta = getMetadata(type);\n\n // Do some extra logic to make unclean strings/citext not create duplicate entities\n const hasAnyStrings = Object.values(meta.allFields).some((f) => f.kind === \"primitive\" && f.type === \"string\");\n const hasAnyCitext = Object.values(meta.allFields).some((f) => f.kind === \"primitive\" && f.citext);\n\n // The `where` here will be later used to `em.create`, so we only clean (not lower for citext)\n where = hasAnyStrings ? maybeClean(meta, where) : where;\n // Use `whereFilterHash` to batch the same `findOrCreate` `where: { firstName: \"a1\" }` calls together to avoid duplicates\n const whereValue = whereFilterHash(\n // Only maybeLower for the purposes of batching, not the later em.create\n hasAnyCitext ? maybeLower(meta, where) : (where as any),\n );\n const batchKey = `${type.name}-${whereValue}-${softDeletes}`;\n\n const invalidKeys = Object.keys(where).filter((key) => {\n const field = meta.allFields[key] ?? fail(`Invalid field ${key}`);\n const supported =\n field.kind === \"primitive\" || field.kind === \"enum\" || field.kind === \"m2o\" || field.kind === \"poly\";\n return !supported;\n });\n if (invalidKeys.length > 0) {\n throw new Error(\n \"findOrCreate only supports primitive, enum, m2o, or poly fields in the where clause. Invalid keys: \" +\n invalidKeys.join(\", \"),\n );\n }\n\n return em.getLoader<FindOrCreateKey<T>, T>(\n \"find-or-create\",\n batchKey,\n async (keys) => {\n // Because our `batchKey` is based only on the `where` clause, if the user called\n // `findOrCreate({ firstName: a1 })` multiple times, but with different ifNew/upsert\n // conditions (or different cased strings and is using a citext column), we'll end\n // up with multiple keys...\n //\n // This is fundamentally asking to create the same entity, but with different ifNew/upsert\n // conditions, which we could fail on, but for now just take the first tuple of\n // {where/ifNew/upsert} and assume it wins\n const [{ ifNew, upsert }] = keys;\n\n // Before we find/create an entity, see if we have a maybe-new one in the EM already.\n // This will also use any WIP changes we've made to the found entity, which ideally is\n // something `em.find` would do as well, but its queries are much more complex...\n const inMemory = em.filterEntities(type, where);\n if (inMemory.length > 1) {\n throw new TooManyError(`Found more than one existing ${type.name} with ${whereAsString(where)}`);\n } else if (inMemory.length === 1) {\n const entity = inMemory[0] as T;\n resurrectIfSoftDeleted(entity);\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n }\n\n // If there is a param like `{ publisher: newPublisherEntity }`, then we know that\n // an entity matching this condition can't be in the db anyway, so skip the em.find\n const hasNewEntityParam = Object.values(where).some((v) => isEntity(v) && v.isNewEntity);\n if (hasNewEntityParam) {\n const entity = em.create(type, { ...where, ...(ifNew as object) } as OptsOf<T>);\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n }\n\n // If we didn't find it in the EM, do the db query/em.create\n const entities = await em.find(\n type,\n // Convert `publisher: undefined` --> `publisher: null`, and we need to make a copy anyway\n Object.fromEntries(Object.entries(where).map(([k, v]) => [k, v === undefined ? null : v])) as any,\n // Always include soft-deleted rows so findOrCreate can resurrect them instead of creating duplicates.\n { softDeletes: \"include\" },\n );\n let entity: T;\n if (entities.length > 1) {\n throw new TooManyError(`Found more than one existing ${type.name} with ${whereAsString(where)}`);\n } else if (entities.length === 1) {\n entity = entities[0];\n resurrectIfSoftDeleted(entity);\n } else {\n entity = em.create(type, { ...where, ...(ifNew as object) } as OptsOf<T>);\n }\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n },\n // Our filter tuple is a complex object, so use a stable cache key to ensure caching works.\n { cacheKeyFn: whereFilterHash as any },\n );\n}\n\n/** A simple in-memory match of `entity` against a mini/flat \"WHERE clause\" of `opts`. */\nexport function entityMatches<T extends Entity>(entity: T, opts: Partial<OptsOf<T>>): boolean {\n const meta = getMetadata(entity);\n return Object.entries(opts).every(([fieldName, value]) => {\n const field = meta.allFields[fieldName] ?? fail(`Invalid field ${fieldName}`);\n const fn = fieldName as keyof T & keyof OptsOf<T>;\n switch (field.kind) {\n case \"primaryKey\":\n case \"enum\":\n return entity[fn] === value;\n case \"primitive\":\n if (field.citext) {\n return compareCaseInsensitive(entity[fn], value);\n } else {\n return entity[fn] === value;\n }\n case \"m2o\":\n case \"poly\":\n const relation = entity[fn] as ManyToOneReference<T, any, any> | PolymorphicReference<T, any, any>;\n if (isLoadedReference(relation)) {\n // Prefer using `.get` because it will handle new/id-less entities\n return sameEntity(relation.get, value as any);\n } else if (relation.isSet) {\n // Otherwise use ids\n return sameEntity(relation.id as any, value as any);\n } else {\n // Relation is not set, match if value is null-ish (null or undefined)\n return value === undefined || value === null;\n }\n default:\n throw new Error(`Unsupported field ${fieldName}`);\n }\n });\n}\n\nfunction whereAsString(where: object): string {\n return Object.entries(where)\n .map(([key, value]) => `${key}=${value}`)\n .join(\", \");\n}\n\nfunction compareCaseInsensitive(str1: any, str2: any): boolean {\n return (\n str1 !== undefined &&\n str2 !== undefined &&\n typeof str1 === \"string\" &&\n typeof str2 === \"string\" &&\n str1.toLowerCase() === str2.toLowerCase()\n );\n}\n\n/** Returns a copy of `opts` with any `citext` values turned to lower case. */\nfunction maybeLower<T extends object | undefined>(meta: EntityMetadata, opts: T): T {\n if (!opts) return opts;\n let result: T | undefined = undefined;\n for (const k in opts) {\n const v = (opts as any)[k];\n const field = meta.allFields[k];\n const isString = field && field.kind === \"primitive\" && field.type === \"string\";\n if (isString && field.citext) {\n const lowered = v?.toLowerCase();\n if (lowered !== v) {\n result ??= { ...opts };\n (result as any)[k] = lowered as any;\n }\n }\n }\n return result ?? opts;\n}\n\n/** Returns a copy of `opts` with any string values trimmed/coalesced. */\nfunction maybeClean<T extends object | undefined>(meta: EntityMetadata, opts: T): T {\n if (!opts) return opts;\n let result: T | undefined = undefined;\n for (const k in opts) {\n const v = (opts as any)[k];\n const field = meta.allFields[k];\n const isString = field && field.kind === \"primitive\" && field.type === \"string\";\n if (isString && field.sanitize !== false) {\n const cleaned = cleanStringValue(v);\n if (cleaned !== v) {\n result ??= { ...opts }; // Only create copy on first change\n (result as any)[k] = cleaned;\n }\n }\n }\n return result ?? opts;\n}\n"],"mappings":";;;;;;;;;AAeA,SAAgB,uBACd,IACA,MACA,OACA,aACmC;CACnC,MAAM,OAAO,YAAY,IAAI;CAG7B,MAAM,gBAAgB,OAAO,OAAO,KAAK,SAAS,CAAC,CAAC,MAAM,MAAM,EAAE,SAAS,eAAe,EAAE,SAAS,QAAQ;CAC7G,MAAM,eAAe,OAAO,OAAO,KAAK,SAAS,CAAC,CAAC,MAAM,MAAM,EAAE,SAAS,eAAe,EAAE,MAAM;CAGjG,QAAQ,gBAAgB,WAAW,MAAM,KAAK,IAAI;CAElD,MAAM,aAAa,gBAEjB,eAAe,WAAW,MAAM,KAAK,IAAK,KAC5C;CACA,MAAM,WAAW,GAAG,KAAK,KAAK,GAAG,WAAW,GAAG;CAE/C,MAAM,cAAc,OAAO,KAAK,KAAK,CAAC,CAAC,QAAQ,QAAQ;EACrD,MAAM,QAAQ,KAAK,UAAU,QAAQ,KAAK,iBAAiB,KAAK;EAGhE,OAAO,EADL,MAAM,SAAS,eAAe,MAAM,SAAS,UAAU,MAAM,SAAS,SAAS,MAAM,SAAS;CAElG,CAAC;CACD,IAAI,YAAY,SAAS,GACvB,MAAM,IAAI,MACR,wGACE,YAAY,KAAK,IAAI,CACzB;CAGF,OAAO,GAAG,UACR,kBACA,UACA,OAAO,SAAS;EASd,MAAM,CAAC,EAAE,OAAO,YAAY;EAK5B,MAAM,WAAW,GAAG,eAAe,MAAM,KAAK;EAC9C,IAAI,SAAS,SAAS,GACpB,MAAM,IAAI,aAAa,gCAAgC,KAAK,KAAK,QAAQ,cAAc,KAAK,GAAG;OAC1F,IAAI,SAAS,WAAW,GAAG;GAChC,MAAM,SAAS,SAAS;GACxB,uBAAuB,MAAM;GAC7B,IAAI,QACF,OAAO,IAAI,MAAM;GAEnB,OAAO,KAAK,UAAU,MAAM;EAC9B;EAKA,IAD0B,OAAO,OAAO,KAAK,CAAC,CAAC,MAAM,MAAM,SAAS,CAAC,KAAK,EAAE,WACxD,GAAG;GACrB,MAAM,SAAS,GAAG,OAAO,MAAM;IAAE,GAAG;IAAO,GAAI;GAAiB,CAAc;GAC9E,IAAI,QACF,OAAO,IAAI,MAAM;GAEnB,OAAO,KAAK,UAAU,MAAM;EAC9B;EAGA,MAAM,WAAW,MAAM,GAAG,KACxB,MAEA,OAAO,YAAY,OAAO,QAAQ,KAAK,CAAC,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,GAAG,MAAM,KAAA,IAAY,OAAO,CAAC,CAAC,CAAC,GAEzF,EAAE,aAAa,UAAU,CAC3B;EACA,IAAI;EACJ,IAAI,SAAS,SAAS,GACpB,MAAM,IAAI,aAAa,gCAAgC,KAAK,KAAK,QAAQ,cAAc,KAAK,GAAG;OAC1F,IAAI,SAAS,WAAW,GAAG;GAChC,SAAS,SAAS;GAClB,uBAAuB,MAAM;EAC/B,OACE,SAAS,GAAG,OAAO,MAAM;GAAE,GAAG;GAAO,GAAI;EAAiB,CAAc;EAE1E,IAAI,QACF,OAAO,IAAI,MAAM;EAEnB,OAAO,KAAK,UAAU,MAAM;CAC9B,GAEA,EAAE,YAAY,gBAAuB,CACvC;AACF;;AAGA,SAAgB,cAAgC,QAAW,MAAmC;CAC5F,MAAM,OAAO,YAAY,MAAM;CAC/B,OAAO,OAAO,QAAQ,IAAI,CAAC,CAAC,OAAO,CAAC,WAAW,WAAW;EACxD,MAAM,QAAQ,KAAK,UAAU,cAAc,KAAK,iBAAiB,WAAW;EAC5E,MAAM,KAAK;EACX,QAAQ,MAAM,MAAd;GACE,KAAK;GACL,KAAK,QACH,OAAO,OAAO,QAAQ;GACxB,KAAK,aACH,IAAI,MAAM,QACR,OAAO,uBAAuB,OAAO,KAAK,KAAK;QAE/C,OAAO,OAAO,QAAQ;GAE1B,KAAK;GACL,KAAK;IACH,MAAM,WAAW,OAAO;IACxB,IAAI,kBAAkB,QAAQ,GAE5B,OAAO,WAAW,SAAS,KAAK,KAAY;SACvC,IAAI,SAAS,OAElB,OAAO,WAAW,SAAS,IAAW,KAAY;SAGlD,OAAO,UAAU,KAAA,KAAa,UAAU;GAE5C,SACE,MAAM,IAAI,MAAM,qBAAqB,WAAW;EACpD;CACF,CAAC;AACH;AAEA,SAAS,cAAc,OAAuB;CAC5C,OAAO,OAAO,QAAQ,KAAK,CAAC,CACzB,KAAK,CAAC,KAAK,WAAW,GAAG,IAAI,GAAG,OAAO,CAAC,CACxC,KAAK,IAAI;AACd;AAEA,SAAS,uBAAuB,MAAW,MAAoB;CAC7D,OACE,SAAS,KAAA,KACT,SAAS,KAAA,KACT,OAAO,SAAS,YAChB,OAAO,SAAS,YAChB,KAAK,YAAY,MAAM,KAAK,YAAY;AAE5C;;AAGA,SAAS,WAAyC,MAAsB,MAAY;CAClF,IAAI,CAAC,MAAM,OAAO;CAClB,IAAI,SAAwB,KAAA;CAC5B,KAAK,MAAM,KAAK,MAAM;EACpB,MAAM,IAAK,KAAa;EACxB,MAAM,QAAQ,KAAK,UAAU;EAE7B,IADiB,SAAS,MAAM,SAAS,eAAe,MAAM,SAAS,YACvD,MAAM,QAAQ;GAC5B,MAAM,UAAU,GAAG,YAAY;GAC/B,IAAI,YAAY,GAAG;IACjB,WAAW,EAAE,GAAG,KAAK;IACrB,OAAgB,KAAK;GACvB;EACF;CACF;CACA,OAAO,UAAU;AACnB;;AAGA,SAAS,WAAyC,MAAsB,MAAY;CAClF,IAAI,CAAC,MAAM,OAAO;CAClB,IAAI,SAAwB,KAAA;CAC5B,KAAK,MAAM,KAAK,MAAM;EACpB,MAAM,IAAK,KAAa;EACxB,MAAM,QAAQ,KAAK,UAAU;EAE7B,IADiB,SAAS,MAAM,SAAS,eAAe,MAAM,SAAS,YACvD,MAAM,aAAa,OAAO;GACxC,MAAM,UAAU,iBAAiB,CAAC;GAClC,IAAI,YAAY,GAAG;IACjB,WAAW,EAAE,GAAG,KAAK;IACrB,OAAgB,KAAK;GACvB;EACF;CACF;CACA,OAAO,UAAU;AACnB"}
1
+ {"version":3,"file":"findOrCreateDataLoader.js","names":[],"sources":["../../src/dataloaders/findOrCreateDataLoader.ts"],"sourcesContent":["import type DataLoader from \"dataloader\";\nimport { whereFilterHash } from \"src/dataloaders/findDataLoader.ts\";\nimport { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityConstructor, type EntityManager, TooManyError, sameEntity } from \"src/EntityManager.ts\";\nimport { type EntityMetadata, getMetadata } from \"src/EntityMetadata.ts\";\nimport { equalArrays } from \"src/fields.ts\";\nimport { type ManyToOneReference, type PolymorphicReference, isLoadedReference } from \"src/relations/index.ts\";\nimport { newWhereEqual, resurrectIfSoftDeleted } from \"src/resurrection.ts\";\nimport type { OptsOf } from \"src/typeMap.ts\";\nimport { cleanStringValue, fail } from \"src/utils.ts\";\n\ninterface FindOrCreateKey<T extends Entity> {\n ifNew: OptsOf<T>;\n upsert: Partial<OptsOf<T>> | undefined;\n}\n\nexport function findOrCreateDataLoader<T extends Entity>(\n em: EntityManager,\n type: EntityConstructor<T>,\n where: Partial<OptsOf<T>>,\n softDeletes: \"include\" | \"exclude\",\n): DataLoader<FindOrCreateKey<T>, T> {\n const meta = getMetadata(type);\n\n // Do some extra logic to make unclean strings/citext not create duplicate entities\n const hasAnyStrings = Object.values(meta.allFields).some((f) => f.kind === \"primitive\" && f.type === \"string\");\n const hasAnyCitext = Object.values(meta.allFields).some((f) => f.kind === \"primitive\" && f.citext);\n\n // The `where` here will be later used to `em.create`, so we only clean (not lower for citext)\n where = hasAnyStrings ? maybeClean(meta, where) : where;\n // Use `whereFilterHash` to batch the same `findOrCreate` `where: { firstName: \"a1\" }` calls together to avoid duplicates\n const whereValue = whereFilterHash(\n // Only maybeLower for the purposes of batching, not the later em.create\n hasAnyCitext ? maybeLower(meta, where) : (where as any),\n );\n const batchKey = `${type.name}-${whereValue}-${softDeletes}`;\n\n const invalidKeys = Object.keys(where).filter((key) => {\n const field = meta.allFields[key] ?? fail(`Invalid field ${key}`);\n const supported =\n field.kind === \"primitive\" || field.kind === \"enum\" || field.kind === \"m2o\" || field.kind === \"poly\";\n return !supported;\n });\n if (invalidKeys.length > 0) {\n throw new Error(\n \"findOrCreate only supports primitive, enum, m2o, or poly fields in the where clause. Invalid keys: \" +\n invalidKeys.join(\", \"),\n );\n }\n\n return em.getLoader<FindOrCreateKey<T>, T>(\n \"find-or-create\",\n batchKey,\n async (keys) => {\n // Because our `batchKey` is based only on the `where` clause, if the user called\n // `findOrCreate({ firstName: a1 })` multiple times, but with different ifNew/upsert\n // conditions (or different cased strings and is using a citext column), we'll end\n // up with multiple keys...\n //\n // This is fundamentally asking to create the same entity, but with different ifNew/upsert\n // conditions, which we could fail on, but for now just take the first tuple of\n // {where/ifNew/upsert} and assume it wins\n const [{ ifNew, upsert }] = keys;\n\n // Before we find/create an entity, see if we have a maybe-new one in the EM already.\n // This will also use any WIP changes we've made to the found entity, which ideally is\n // something `em.find` would do as well, but its queries are much more complex...\n const inMemory = em.filterEntities(type, where);\n if (inMemory.length > 1) {\n throw new TooManyError(`Found more than one existing ${type.name} with ${whereAsString(where)}`);\n } else if (inMemory.length === 1) {\n const entity = inMemory[0] as T;\n resurrectIfSoftDeleted(entity);\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n }\n\n // If there is a param like `{ publisher: newPublisherEntity }`, then we know that\n // an entity matching this condition can't be in the db anyway, so skip the em.find\n const hasNewEntityParam = Object.values(where).some((v) => isEntity(v) && v.isNewEntity);\n if (hasNewEntityParam) {\n const entity = em.create(type, { ...where, ...(ifNew as object) } as OptsOf<T>);\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n }\n\n // If we didn't find it in the EM, do the db query/em.create\n const entities = await em.find(\n type,\n newWhereEqual(meta, where) as any,\n // Always include soft-deleted rows so findOrCreate can resurrect them instead of creating duplicates.\n { softDeletes: \"include\" },\n );\n let entity: T;\n if (entities.length > 1) {\n throw new TooManyError(`Found more than one existing ${type.name} with ${whereAsString(where)}`);\n } else if (entities.length === 1) {\n entity = entities[0];\n resurrectIfSoftDeleted(entity);\n } else {\n entity = em.create(type, { ...where, ...(ifNew as object) } as OptsOf<T>);\n }\n if (upsert) {\n entity.set(upsert);\n }\n return keys.map(() => entity);\n },\n // Our filter tuple is a complex object, so use a stable cache key to ensure caching works.\n { cacheKeyFn: whereFilterHash as any },\n );\n}\n\n/** A simple in-memory match of `entity` against a mini/flat \"WHERE clause\" of `opts`. */\nexport function entityMatches<T extends Entity>(entity: T, opts: Partial<OptsOf<T>>): boolean {\n const meta = getMetadata(entity);\n return Object.entries(opts).every(([fieldName, value]) => {\n const field = meta.allFields[fieldName] ?? fail(`Invalid field ${fieldName}`);\n const fn = fieldName as keyof T & keyof OptsOf<T>;\n switch (field.kind) {\n case \"primaryKey\":\n case \"enum\":\n return entity[fn] === value;\n case \"primitive\":\n if (meta.columns[fieldName]?.isArray) {\n const current = entity[fn];\n return Array.isArray(current) && Array.isArray(value) ? equalArrays(current, value) : current === value;\n } else if (field.citext) {\n return compareCaseInsensitive(entity[fn], value);\n } else {\n return entity[fn] === value;\n }\n case \"m2o\":\n case \"poly\":\n const relation = entity[fn] as ManyToOneReference<T, any, any> | PolymorphicReference<T, any, any>;\n if (isLoadedReference(relation)) {\n // Prefer using `.get` because it will handle new/id-less entities\n return sameEntity(relation.get, value as any);\n } else if (relation.isSet) {\n // Otherwise use ids\n return sameEntity(relation.id as any, value as any);\n } else {\n // Relation is not set, match if value is null-ish (null or undefined)\n return value === undefined || value === null;\n }\n default:\n throw new Error(`Unsupported field ${fieldName}`);\n }\n });\n}\n\nfunction whereAsString(where: object): string {\n return Object.entries(where)\n .map(([key, value]) => `${key}=${value}`)\n .join(\", \");\n}\n\nfunction compareCaseInsensitive(str1: any, str2: any): boolean {\n return (\n str1 !== undefined &&\n str2 !== undefined &&\n typeof str1 === \"string\" &&\n typeof str2 === \"string\" &&\n str1.toLowerCase() === str2.toLowerCase()\n );\n}\n\n/** Returns a copy of `opts` with any `citext` values turned to lower case. */\nfunction maybeLower<T extends object | undefined>(meta: EntityMetadata, opts: T): T {\n if (!opts) return opts;\n let result: T | undefined = undefined;\n for (const k in opts) {\n const v = (opts as any)[k];\n const field = meta.allFields[k];\n const isString = field && field.kind === \"primitive\" && field.type === \"string\";\n if (isString && field.citext) {\n const lowered = v?.toLowerCase();\n if (lowered !== v) {\n result ??= { ...opts };\n (result as any)[k] = lowered as any;\n }\n }\n }\n return result ?? opts;\n}\n\n/** Returns a copy of `opts` with any string values trimmed/coalesced. */\nfunction maybeClean<T extends object | undefined>(meta: EntityMetadata, opts: T): T {\n if (!opts) return opts;\n let result: T | undefined = undefined;\n for (const k in opts) {\n const v = (opts as any)[k];\n const field = meta.allFields[k];\n const isString = field && field.kind === \"primitive\" && field.type === \"string\";\n if (isString && field.sanitize !== false) {\n const cleaned = cleanStringValue(v);\n if (cleaned !== v) {\n result ??= { ...opts }; // Only create copy on first change\n (result as any)[k] = cleaned;\n }\n }\n }\n return result ?? opts;\n}\n"],"mappings":"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"}
@@ -24,7 +24,7 @@ async function findExistingIfUniqueBy(em, constructor, input) {
24
24
  return inMemory[0];
25
25
  }
26
26
  if (Object.values(where).some((value) => require_Entity.isEntity(value) && value.isNewEntity)) continue;
27
- const entities = onlySoftDeleted(await em.find(constructor, where, { softDeletes: "include" }));
27
+ const entities = onlySoftDeleted(await em.find(constructor, newWhereEqual(meta, where), { softDeletes: "include" }));
28
28
  if (entities.length > 1) throw new require_EntityManager.TooManyError(`Found more than one existing ${meta.type} with ${whereAsString(where)}`);
29
29
  else if (entities.length === 1) {
30
30
  resurrectIfSoftDeleted(entities[0]);
@@ -67,8 +67,18 @@ function isQueryableIdentityField(field) {
67
67
  function whereAsString(where) {
68
68
  return Object.entries(where).map(([key, value]) => `${key}=${value}`).join(", ");
69
69
  }
70
+ /** Builds exact identity filters for primitives and relations. */
71
+ function newWhereEqual(meta, input) {
72
+ const where = {};
73
+ for (const name of Object.keys(input)) {
74
+ const value = input[name] ?? null;
75
+ where[name] = meta.allFields[name]?.kind === "primitive" ? { eq: value } : value;
76
+ }
77
+ return where;
78
+ }
70
79
  //#endregion
71
80
  exports.findExistingIfUniqueBy = findExistingIfUniqueBy;
81
+ exports.newWhereEqual = newWhereEqual;
72
82
  exports.resurrectIfSoftDeleted = resurrectIfSoftDeleted;
73
83
 
74
84
  //# sourceMappingURL=resurrection.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"resurrection.cjs","names":["getBaseMeta","getMetadata","TooManyError","isEntity"],"sources":["../src/resurrection.ts"],"sourcesContent":["import { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityManager, type MaybeAbstractEntityConstructor, TooManyError } from \"src/EntityManager.ts\";\nimport { type EntityMetadata, type Field, getBaseMeta, getMetadata } from \"src/EntityMetadata.ts\";\nimport { setField } from \"src/fields.ts\";\n\n/** Clears the soft-delete marker so a matched entity is updated instead of recreated. */\nexport function resurrectIfSoftDeleted(entity: Entity): void {\n const deletedAt = getBaseMeta(getMetadata(entity)).timestampFields?.deletedAt;\n if (deletedAt && (entity as any).isSoftDeletedEntity) {\n setField(entity, deletedAt, undefined);\n }\n}\n\n/**\n * Finds an existing entity when the input fully covers one configured `uniqueBy` candidate.\n * I.e. with `uniqueBy = [[\"email\"], [\"author\", \"title\"]]`, `{ email }` or `{ authorId, title }` can lookup an existing row.\n */\nexport async function findExistingIfUniqueBy<T extends Entity>(\n em: EntityManager<any, any, any>,\n constructor: MaybeAbstractEntityConstructor<T>,\n input: object,\n): Promise<T | undefined> {\n const meta = getMetadata(constructor);\n const candidates = getUniqueByCandidates(meta, input);\n for (const where of candidates) {\n const inMemory = onlySoftDeleted(em.filterEntities(constructor as any, where as any) as T[]);\n if (inMemory.length > 1) {\n throw new TooManyError(`Found more than one existing ${meta.type} with ${whereAsString(where)}`);\n } else if (inMemory.length === 1) {\n resurrectIfSoftDeleted(inMemory[0]);\n return inMemory[0];\n }\n\n // I.e. `{ publisher: newPublisherEntity }` cannot already exist in the database, so skip the `em.find`.\n const hasNewEntityParam = Object.values(where).some((value) => isEntity(value) && value.isNewEntity);\n if (hasNewEntityParam) continue;\n\n const entities = onlySoftDeleted(await (em as any).find(constructor, where, { softDeletes: \"include\" })) as T[];\n if (entities.length > 1) {\n throw new TooManyError(`Found more than one existing ${meta.type} with ${whereAsString(where)}`);\n } else if (entities.length === 1) {\n resurrectIfSoftDeleted(entities[0]);\n return entities[0];\n }\n }\n return undefined;\n}\n\n/** Returns soft-deleted entities that are safe for upsert to resurrect. */\nfunction onlySoftDeleted<T extends Entity>(entities: T[]): T[] {\n return entities.filter(\n (entity) => !entity.isDeletedEntity && (entity as T & { isSoftDeletedEntity?: boolean }).isSoftDeletedEntity,\n );\n}\n\n/** Builds pushdown-safe where clauses from configured identity fields. */\nfunction getUniqueByCandidates(meta: EntityMetadata, input: object): object[] {\n return (meta.uniqueBy ?? getBaseMeta(meta).uniqueBy ?? []).flatMap((fields) => {\n const where: Record<string, unknown> = {};\n for (const fieldName of fields) {\n const field = meta.allFields[fieldName];\n if (!field || !isQueryableIdentityField(field)) return [];\n const value = getIdentityValue(field, input);\n if (value === undefined || value === null) return [];\n where[field.fieldName] = value;\n }\n return [where];\n });\n}\n\n/** Returns the identity value from the input. */\nfunction getIdentityValue(field: Field, input: object): unknown {\n if (Object.hasOwn(input, field.fieldName)) {\n return normalizeIdentityValue((input as Record<string, unknown>)[field.fieldName]);\n }\n if (field.fieldIdName && Object.hasOwn(input, field.fieldIdName)) {\n return normalizeIdentityValue((input as Record<string, unknown>)[field.fieldIdName]);\n }\n return undefined;\n}\n\n/** Returns raw ids from relation inputs, i.e. `{ publisher: { id: \"p:1\" } }` becomes `{ publisher: \"p:1\" }`. */\nfunction normalizeIdentityValue(value: unknown): unknown {\n if (!isEntity(value) && value && typeof value === \"object\" && Object.hasOwn(value, \"id\")) {\n return (value as { id?: unknown }).id;\n }\n return value;\n}\n\n/** Returns true if Joist can push this identity field into a flat find clause. */\nfunction isQueryableIdentityField(field: Field): boolean {\n return field.kind === \"primitive\" || field.kind === \"enum\" || field.kind === \"m2o\" || field.kind === \"poly\";\n}\n\nfunction whereAsString(where: object): string {\n return Object.entries(where)\n .map(([key, value]) => `${key}=${value}`)\n .join(\", \");\n}\n"],"mappings":";;;;;;;AAMA,SAAgB,uBAAuB,QAAsB;CAC3D,MAAM,YAAYA,uBAAAA,YAAYC,uBAAAA,YAAY,MAAM,CAAC,CAAC,CAAC,iBAAiB;CACpE,IAAI,aAAc,OAAe,qBAC/B,eAAA,SAAS,QAAQ,WAAW,KAAA,CAAS;AAEzC;;;;;AAMA,eAAsB,uBACpB,IACA,aACA,OACwB;CACxB,MAAM,OAAOA,uBAAAA,YAAY,WAAW;CACpC,MAAM,aAAa,sBAAsB,MAAM,KAAK;CACpD,KAAK,MAAM,SAAS,YAAY;EAC9B,MAAM,WAAW,gBAAgB,GAAG,eAAe,aAAoB,KAAY,CAAQ;EAC3F,IAAI,SAAS,SAAS,GACpB,MAAM,IAAIC,sBAAAA,aAAa,gCAAgC,KAAK,KAAK,QAAQ,cAAc,KAAK,GAAG;OAC1F,IAAI,SAAS,WAAW,GAAG;GAChC,uBAAuB,SAAS,EAAE;GAClC,OAAO,SAAS;EAClB;EAIA,IAD0B,OAAO,OAAO,KAAK,CAAC,CAAC,MAAM,UAAUC,eAAAA,SAAS,KAAK,KAAK,MAAM,WACpE,GAAG;EAEvB,MAAM,WAAW,gBAAgB,MAAO,GAAW,KAAK,aAAa,OAAO,EAAE,aAAa,UAAU,CAAC,CAAC;EACvG,IAAI,SAAS,SAAS,GACpB,MAAM,IAAID,sBAAAA,aAAa,gCAAgC,KAAK,KAAK,QAAQ,cAAc,KAAK,GAAG;OAC1F,IAAI,SAAS,WAAW,GAAG;GAChC,uBAAuB,SAAS,EAAE;GAClC,OAAO,SAAS;EAClB;CACF;AAEF;;AAGA,SAAS,gBAAkC,UAAoB;CAC7D,OAAO,SAAS,QACb,WAAW,CAAC,OAAO,mBAAoB,OAAiD,mBAC3F;AACF;;AAGA,SAAS,sBAAsB,MAAsB,OAAyB;CAC5E,QAAQ,KAAK,YAAYF,uBAAAA,YAAY,IAAI,CAAC,CAAC,YAAY,CAAC,EAAA,CAAG,SAAS,WAAW;EAC7E,MAAM,QAAiC,CAAC;EACxC,KAAK,MAAM,aAAa,QAAQ;GAC9B,MAAM,QAAQ,KAAK,UAAU;GAC7B,IAAI,CAAC,SAAS,CAAC,yBAAyB,KAAK,GAAG,OAAO,CAAC;GACxD,MAAM,QAAQ,iBAAiB,OAAO,KAAK;GAC3C,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,OAAO,CAAC;GACnD,MAAM,MAAM,aAAa;EAC3B;EACA,OAAO,CAAC,KAAK;CACf,CAAC;AACH;;AAGA,SAAS,iBAAiB,OAAc,OAAwB;CAC9D,IAAI,OAAO,OAAO,OAAO,MAAM,SAAS,GACtC,OAAO,uBAAwB,MAAkC,MAAM,UAAU;CAEnF,IAAI,MAAM,eAAe,OAAO,OAAO,OAAO,MAAM,WAAW,GAC7D,OAAO,uBAAwB,MAAkC,MAAM,YAAY;AAGvF;;AAGA,SAAS,uBAAuB,OAAyB;CACvD,IAAI,CAACG,eAAAA,SAAS,KAAK,KAAK,SAAS,OAAO,UAAU,YAAY,OAAO,OAAO,OAAO,IAAI,GACrF,OAAQ,MAA2B;CAErC,OAAO;AACT;;AAGA,SAAS,yBAAyB,OAAuB;CACvD,OAAO,MAAM,SAAS,eAAe,MAAM,SAAS,UAAU,MAAM,SAAS,SAAS,MAAM,SAAS;AACvG;AAEA,SAAS,cAAc,OAAuB;CAC5C,OAAO,OAAO,QAAQ,KAAK,CAAC,CACzB,KAAK,CAAC,KAAK,WAAW,GAAG,IAAI,GAAG,OAAO,CAAC,CACxC,KAAK,IAAI;AACd"}
1
+ {"version":3,"file":"resurrection.cjs","names":["getBaseMeta","getMetadata","TooManyError","isEntity"],"sources":["../src/resurrection.ts"],"sourcesContent":["import { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityManager, type MaybeAbstractEntityConstructor, TooManyError } from \"src/EntityManager.ts\";\nimport { type EntityMetadata, type Field, getBaseMeta, getMetadata } from \"src/EntityMetadata.ts\";\nimport { setField } from \"src/fields.ts\";\n\n/** Clears the soft-delete marker so a matched entity is updated instead of recreated. */\nexport function resurrectIfSoftDeleted(entity: Entity): void {\n const deletedAt = getBaseMeta(getMetadata(entity)).timestampFields?.deletedAt;\n if (deletedAt && (entity as any).isSoftDeletedEntity) {\n setField(entity, deletedAt, undefined);\n }\n}\n\n/**\n * Finds an existing entity when the input fully covers one configured `uniqueBy` candidate.\n * I.e. with `uniqueBy = [[\"email\"], [\"author\", \"title\"]]`, `{ email }` or `{ authorId, title }` can lookup an existing row.\n */\nexport async function findExistingIfUniqueBy<T extends Entity>(\n em: EntityManager<any, any, any>,\n constructor: MaybeAbstractEntityConstructor<T>,\n input: object,\n): Promise<T | undefined> {\n const meta = getMetadata(constructor);\n const candidates = getUniqueByCandidates(meta, input);\n for (const where of candidates) {\n const inMemory = onlySoftDeleted(em.filterEntities(constructor as any, where as any) as T[]);\n if (inMemory.length > 1) {\n throw new TooManyError(`Found more than one existing ${meta.type} with ${whereAsString(where)}`);\n } else if (inMemory.length === 1) {\n resurrectIfSoftDeleted(inMemory[0]);\n return inMemory[0];\n }\n\n // I.e. `{ publisher: newPublisherEntity }` cannot already exist in the database, so skip the `em.find`.\n const hasNewEntityParam = Object.values(where).some((value) => isEntity(value) && value.isNewEntity);\n if (hasNewEntityParam) continue;\n\n const entities = onlySoftDeleted(\n await (em as any).find(constructor, newWhereEqual(meta, where), { softDeletes: \"include\" }),\n ) as T[];\n if (entities.length > 1) {\n throw new TooManyError(`Found more than one existing ${meta.type} with ${whereAsString(where)}`);\n } else if (entities.length === 1) {\n resurrectIfSoftDeleted(entities[0]);\n return entities[0];\n }\n }\n return undefined;\n}\n\n/** Returns soft-deleted entities that are safe for upsert to resurrect. */\nfunction onlySoftDeleted<T extends Entity>(entities: T[]): T[] {\n return entities.filter(\n (entity) => !entity.isDeletedEntity && (entity as T & { isSoftDeletedEntity?: boolean }).isSoftDeletedEntity,\n );\n}\n\n/** Builds pushdown-safe where clauses from configured identity fields. */\nfunction getUniqueByCandidates(meta: EntityMetadata, input: object): object[] {\n return (meta.uniqueBy ?? getBaseMeta(meta).uniqueBy ?? []).flatMap((fields) => {\n const where: Record<string, unknown> = {};\n for (const fieldName of fields) {\n const field = meta.allFields[fieldName];\n if (!field || !isQueryableIdentityField(field)) return [];\n const value = getIdentityValue(field, input);\n if (value === undefined || value === null) return [];\n where[field.fieldName] = value;\n }\n return [where];\n });\n}\n\n/** Returns the identity value from the input. */\nfunction getIdentityValue(field: Field, input: object): unknown {\n if (Object.hasOwn(input, field.fieldName)) {\n return normalizeIdentityValue((input as Record<string, unknown>)[field.fieldName]);\n }\n if (field.fieldIdName && Object.hasOwn(input, field.fieldIdName)) {\n return normalizeIdentityValue((input as Record<string, unknown>)[field.fieldIdName]);\n }\n return undefined;\n}\n\n/** Returns raw ids from relation inputs, i.e. `{ publisher: { id: \"p:1\" } }` becomes `{ publisher: \"p:1\" }`. */\nfunction normalizeIdentityValue(value: unknown): unknown {\n if (!isEntity(value) && value && typeof value === \"object\" && Object.hasOwn(value, \"id\")) {\n return (value as { id?: unknown }).id;\n }\n return value;\n}\n\n/** Returns true if Joist can push this identity field into a flat find clause. */\nfunction isQueryableIdentityField(field: Field): boolean {\n return field.kind === \"primitive\" || field.kind === \"enum\" || field.kind === \"m2o\" || field.kind === \"poly\";\n}\n\nfunction whereAsString(where: object): string {\n return Object.entries(where)\n .map(([key, value]) => `${key}=${value}`)\n .join(\", \");\n}\n\n/** Builds exact identity filters for primitives and relations. */\nexport function newWhereEqual(meta: EntityMetadata, input: object): object {\n const where: Record<string, unknown> = {};\n\n // Arrays need eq for exact equality; relations must stay as-is because eq would be a nested entity filter.\n // Unset values must match SQL NULL, e.g. publisher: undefined must not omit the publisher filter.\n for (const name of Object.keys(input)) {\n const value = (input as Record<string, unknown>)[name] ?? null;\n where[name] = meta.allFields[name]?.kind === \"primitive\" ? { eq: value } : value;\n }\n\n return where;\n}\n"],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { EntityMetadata } from "./EntityMetadata.cjs";
1
2
  import { EntityManager, MaybeAbstractEntityConstructor } from "./EntityManager.cjs";
2
3
  import { Entity } from "./Entity.cjs";
3
4
  //#region src/resurrection.d.ts
@@ -8,6 +9,8 @@ declare function resurrectIfSoftDeleted(entity: Entity): void;
8
9
  * I.e. with `uniqueBy = [["email"], ["author", "title"]]`, `{ email }` or `{ authorId, title }` can lookup an existing row.
9
10
  */
10
11
  declare function findExistingIfUniqueBy<T extends Entity>(em: EntityManager<any, any, any>, constructor: MaybeAbstractEntityConstructor<T>, input: object): Promise<T | undefined>;
12
+ /** Builds exact identity filters for primitives and relations. */
13
+ declare function newWhereEqual(meta: EntityMetadata, input: object): object;
11
14
  //#endregion
12
- export { findExistingIfUniqueBy, resurrectIfSoftDeleted };
15
+ export { findExistingIfUniqueBy, newWhereEqual, resurrectIfSoftDeleted };
13
16
  //# sourceMappingURL=resurrection.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"resurrection.d.cts","names":[],"sources":["../src/resurrection.ts"],"mappings":";;;;iBAMgB,uBAAuB,QAAQ;;;;;iBAWzB,uBAAuB,UAAU,QACrD,IAAI,8BACJ,aAAa,+BAA+B,IAC5C,gBACC,QAAQ"}
1
+ {"version":3,"file":"resurrection.d.cts","names":[],"sources":["../src/resurrection.ts"],"mappings":";;;;;iBAMgB,uBAAuB,QAAQ;;;;;iBAWzB,uBAAuB,UAAU,QACrD,IAAI,8BACJ,aAAa,+BAA+B,IAC5C,gBACC,QAAQ;;iBAkFK,cAAc,MAAM,gBAAgB"}
@@ -1,3 +1,4 @@
1
+ import { EntityMetadata } from "./EntityMetadata.mjs";
1
2
  import { EntityManager, MaybeAbstractEntityConstructor } from "./EntityManager.mjs";
2
3
  import { Entity } from "./Entity.mjs";
3
4
  //#region src/resurrection.d.ts
@@ -8,6 +9,8 @@ declare function resurrectIfSoftDeleted(entity: Entity): void;
8
9
  * I.e. with `uniqueBy = [["email"], ["author", "title"]]`, `{ email }` or `{ authorId, title }` can lookup an existing row.
9
10
  */
10
11
  declare function findExistingIfUniqueBy<T extends Entity>(em: EntityManager<any, any, any>, constructor: MaybeAbstractEntityConstructor<T>, input: object): Promise<T | undefined>;
12
+ /** Builds exact identity filters for primitives and relations. */
13
+ declare function newWhereEqual(meta: EntityMetadata, input: object): object;
11
14
  //#endregion
12
- export { findExistingIfUniqueBy, resurrectIfSoftDeleted };
15
+ export { findExistingIfUniqueBy, newWhereEqual, resurrectIfSoftDeleted };
13
16
  //# sourceMappingURL=resurrection.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"resurrection.d.mts","names":[],"sources":["../src/resurrection.ts"],"mappings":";;;;iBAMgB,uBAAuB,QAAQ;;;;;iBAWzB,uBAAuB,UAAU,QACrD,IAAI,8BACJ,aAAa,+BAA+B,IAC5C,gBACC,QAAQ"}
1
+ {"version":3,"file":"resurrection.d.mts","names":[],"sources":["../src/resurrection.ts"],"mappings":";;;;;iBAMgB,uBAAuB,QAAQ;;;;;iBAWzB,uBAAuB,UAAU,QACrD,IAAI,8BACJ,aAAa,+BAA+B,IAC5C,gBACC,QAAQ;;iBAkFK,cAAc,MAAM,gBAAgB"}
@@ -23,7 +23,7 @@ async function findExistingIfUniqueBy(em, constructor, input) {
23
23
  return inMemory[0];
24
24
  }
25
25
  if (Object.values(where).some((value) => isEntity(value) && value.isNewEntity)) continue;
26
- const entities = onlySoftDeleted(await em.find(constructor, where, { softDeletes: "include" }));
26
+ const entities = onlySoftDeleted(await em.find(constructor, newWhereEqual(meta, where), { softDeletes: "include" }));
27
27
  if (entities.length > 1) throw new TooManyError(`Found more than one existing ${meta.type} with ${whereAsString(where)}`);
28
28
  else if (entities.length === 1) {
29
29
  resurrectIfSoftDeleted(entities[0]);
@@ -66,7 +66,16 @@ function isQueryableIdentityField(field) {
66
66
  function whereAsString(where) {
67
67
  return Object.entries(where).map(([key, value]) => `${key}=${value}`).join(", ");
68
68
  }
69
+ /** Builds exact identity filters for primitives and relations. */
70
+ function newWhereEqual(meta, input) {
71
+ const where = {};
72
+ for (const name of Object.keys(input)) {
73
+ const value = input[name] ?? null;
74
+ where[name] = meta.allFields[name]?.kind === "primitive" ? { eq: value } : value;
75
+ }
76
+ return where;
77
+ }
69
78
  //#endregion
70
- export { findExistingIfUniqueBy, resurrectIfSoftDeleted };
79
+ export { findExistingIfUniqueBy, newWhereEqual, resurrectIfSoftDeleted };
71
80
 
72
81
  //# sourceMappingURL=resurrection.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"resurrection.js","names":[],"sources":["../src/resurrection.ts"],"sourcesContent":["import { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityManager, type MaybeAbstractEntityConstructor, TooManyError } from \"src/EntityManager.ts\";\nimport { type EntityMetadata, type Field, getBaseMeta, getMetadata } from \"src/EntityMetadata.ts\";\nimport { setField } from \"src/fields.ts\";\n\n/** Clears the soft-delete marker so a matched entity is updated instead of recreated. */\nexport function resurrectIfSoftDeleted(entity: Entity): void {\n const deletedAt = getBaseMeta(getMetadata(entity)).timestampFields?.deletedAt;\n if (deletedAt && (entity as any).isSoftDeletedEntity) {\n setField(entity, deletedAt, undefined);\n }\n}\n\n/**\n * Finds an existing entity when the input fully covers one configured `uniqueBy` candidate.\n * I.e. with `uniqueBy = [[\"email\"], [\"author\", \"title\"]]`, `{ email }` or `{ authorId, title }` can lookup an existing row.\n */\nexport async function findExistingIfUniqueBy<T extends Entity>(\n em: EntityManager<any, any, any>,\n constructor: MaybeAbstractEntityConstructor<T>,\n input: object,\n): Promise<T | undefined> {\n const meta = getMetadata(constructor);\n const candidates = getUniqueByCandidates(meta, input);\n for (const where of candidates) {\n const inMemory = onlySoftDeleted(em.filterEntities(constructor as any, where as any) as T[]);\n if (inMemory.length > 1) {\n throw new TooManyError(`Found more than one existing ${meta.type} with ${whereAsString(where)}`);\n } else if (inMemory.length === 1) {\n resurrectIfSoftDeleted(inMemory[0]);\n return inMemory[0];\n }\n\n // I.e. `{ publisher: newPublisherEntity }` cannot already exist in the database, so skip the `em.find`.\n const hasNewEntityParam = Object.values(where).some((value) => isEntity(value) && value.isNewEntity);\n if (hasNewEntityParam) continue;\n\n const entities = onlySoftDeleted(await (em as any).find(constructor, where, { softDeletes: \"include\" })) as T[];\n if (entities.length > 1) {\n throw new TooManyError(`Found more than one existing ${meta.type} with ${whereAsString(where)}`);\n } else if (entities.length === 1) {\n resurrectIfSoftDeleted(entities[0]);\n return entities[0];\n }\n }\n return undefined;\n}\n\n/** Returns soft-deleted entities that are safe for upsert to resurrect. */\nfunction onlySoftDeleted<T extends Entity>(entities: T[]): T[] {\n return entities.filter(\n (entity) => !entity.isDeletedEntity && (entity as T & { isSoftDeletedEntity?: boolean }).isSoftDeletedEntity,\n );\n}\n\n/** Builds pushdown-safe where clauses from configured identity fields. */\nfunction getUniqueByCandidates(meta: EntityMetadata, input: object): object[] {\n return (meta.uniqueBy ?? getBaseMeta(meta).uniqueBy ?? []).flatMap((fields) => {\n const where: Record<string, unknown> = {};\n for (const fieldName of fields) {\n const field = meta.allFields[fieldName];\n if (!field || !isQueryableIdentityField(field)) return [];\n const value = getIdentityValue(field, input);\n if (value === undefined || value === null) return [];\n where[field.fieldName] = value;\n }\n return [where];\n });\n}\n\n/** Returns the identity value from the input. */\nfunction getIdentityValue(field: Field, input: object): unknown {\n if (Object.hasOwn(input, field.fieldName)) {\n return normalizeIdentityValue((input as Record<string, unknown>)[field.fieldName]);\n }\n if (field.fieldIdName && Object.hasOwn(input, field.fieldIdName)) {\n return normalizeIdentityValue((input as Record<string, unknown>)[field.fieldIdName]);\n }\n return undefined;\n}\n\n/** Returns raw ids from relation inputs, i.e. `{ publisher: { id: \"p:1\" } }` becomes `{ publisher: \"p:1\" }`. */\nfunction normalizeIdentityValue(value: unknown): unknown {\n if (!isEntity(value) && value && typeof value === \"object\" && Object.hasOwn(value, \"id\")) {\n return (value as { id?: unknown }).id;\n }\n return value;\n}\n\n/** Returns true if Joist can push this identity field into a flat find clause. */\nfunction isQueryableIdentityField(field: Field): boolean {\n return field.kind === \"primitive\" || field.kind === \"enum\" || field.kind === \"m2o\" || field.kind === \"poly\";\n}\n\nfunction whereAsString(where: object): string {\n return Object.entries(where)\n .map(([key, value]) => `${key}=${value}`)\n .join(\", \");\n}\n"],"mappings":";;;;;;AAMA,SAAgB,uBAAuB,QAAsB;CAC3D,MAAM,YAAY,YAAY,YAAY,MAAM,CAAC,CAAC,CAAC,iBAAiB;CACpE,IAAI,aAAc,OAAe,qBAC/B,SAAS,QAAQ,WAAW,KAAA,CAAS;AAEzC;;;;;AAMA,eAAsB,uBACpB,IACA,aACA,OACwB;CACxB,MAAM,OAAO,YAAY,WAAW;CACpC,MAAM,aAAa,sBAAsB,MAAM,KAAK;CACpD,KAAK,MAAM,SAAS,YAAY;EAC9B,MAAM,WAAW,gBAAgB,GAAG,eAAe,aAAoB,KAAY,CAAQ;EAC3F,IAAI,SAAS,SAAS,GACpB,MAAM,IAAI,aAAa,gCAAgC,KAAK,KAAK,QAAQ,cAAc,KAAK,GAAG;OAC1F,IAAI,SAAS,WAAW,GAAG;GAChC,uBAAuB,SAAS,EAAE;GAClC,OAAO,SAAS;EAClB;EAIA,IAD0B,OAAO,OAAO,KAAK,CAAC,CAAC,MAAM,UAAU,SAAS,KAAK,KAAK,MAAM,WACpE,GAAG;EAEvB,MAAM,WAAW,gBAAgB,MAAO,GAAW,KAAK,aAAa,OAAO,EAAE,aAAa,UAAU,CAAC,CAAC;EACvG,IAAI,SAAS,SAAS,GACpB,MAAM,IAAI,aAAa,gCAAgC,KAAK,KAAK,QAAQ,cAAc,KAAK,GAAG;OAC1F,IAAI,SAAS,WAAW,GAAG;GAChC,uBAAuB,SAAS,EAAE;GAClC,OAAO,SAAS;EAClB;CACF;AAEF;;AAGA,SAAS,gBAAkC,UAAoB;CAC7D,OAAO,SAAS,QACb,WAAW,CAAC,OAAO,mBAAoB,OAAiD,mBAC3F;AACF;;AAGA,SAAS,sBAAsB,MAAsB,OAAyB;CAC5E,QAAQ,KAAK,YAAY,YAAY,IAAI,CAAC,CAAC,YAAY,CAAC,EAAA,CAAG,SAAS,WAAW;EAC7E,MAAM,QAAiC,CAAC;EACxC,KAAK,MAAM,aAAa,QAAQ;GAC9B,MAAM,QAAQ,KAAK,UAAU;GAC7B,IAAI,CAAC,SAAS,CAAC,yBAAyB,KAAK,GAAG,OAAO,CAAC;GACxD,MAAM,QAAQ,iBAAiB,OAAO,KAAK;GAC3C,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,OAAO,CAAC;GACnD,MAAM,MAAM,aAAa;EAC3B;EACA,OAAO,CAAC,KAAK;CACf,CAAC;AACH;;AAGA,SAAS,iBAAiB,OAAc,OAAwB;CAC9D,IAAI,OAAO,OAAO,OAAO,MAAM,SAAS,GACtC,OAAO,uBAAwB,MAAkC,MAAM,UAAU;CAEnF,IAAI,MAAM,eAAe,OAAO,OAAO,OAAO,MAAM,WAAW,GAC7D,OAAO,uBAAwB,MAAkC,MAAM,YAAY;AAGvF;;AAGA,SAAS,uBAAuB,OAAyB;CACvD,IAAI,CAAC,SAAS,KAAK,KAAK,SAAS,OAAO,UAAU,YAAY,OAAO,OAAO,OAAO,IAAI,GACrF,OAAQ,MAA2B;CAErC,OAAO;AACT;;AAGA,SAAS,yBAAyB,OAAuB;CACvD,OAAO,MAAM,SAAS,eAAe,MAAM,SAAS,UAAU,MAAM,SAAS,SAAS,MAAM,SAAS;AACvG;AAEA,SAAS,cAAc,OAAuB;CAC5C,OAAO,OAAO,QAAQ,KAAK,CAAC,CACzB,KAAK,CAAC,KAAK,WAAW,GAAG,IAAI,GAAG,OAAO,CAAC,CACxC,KAAK,IAAI;AACd"}
1
+ {"version":3,"file":"resurrection.js","names":[],"sources":["../src/resurrection.ts"],"sourcesContent":["import { type Entity, isEntity } from \"src/Entity.ts\";\nimport { type EntityManager, type MaybeAbstractEntityConstructor, TooManyError } from \"src/EntityManager.ts\";\nimport { type EntityMetadata, type Field, getBaseMeta, getMetadata } from \"src/EntityMetadata.ts\";\nimport { setField } from \"src/fields.ts\";\n\n/** Clears the soft-delete marker so a matched entity is updated instead of recreated. */\nexport function resurrectIfSoftDeleted(entity: Entity): void {\n const deletedAt = getBaseMeta(getMetadata(entity)).timestampFields?.deletedAt;\n if (deletedAt && (entity as any).isSoftDeletedEntity) {\n setField(entity, deletedAt, undefined);\n }\n}\n\n/**\n * Finds an existing entity when the input fully covers one configured `uniqueBy` candidate.\n * I.e. with `uniqueBy = [[\"email\"], [\"author\", \"title\"]]`, `{ email }` or `{ authorId, title }` can lookup an existing row.\n */\nexport async function findExistingIfUniqueBy<T extends Entity>(\n em: EntityManager<any, any, any>,\n constructor: MaybeAbstractEntityConstructor<T>,\n input: object,\n): Promise<T | undefined> {\n const meta = getMetadata(constructor);\n const candidates = getUniqueByCandidates(meta, input);\n for (const where of candidates) {\n const inMemory = onlySoftDeleted(em.filterEntities(constructor as any, where as any) as T[]);\n if (inMemory.length > 1) {\n throw new TooManyError(`Found more than one existing ${meta.type} with ${whereAsString(where)}`);\n } else if (inMemory.length === 1) {\n resurrectIfSoftDeleted(inMemory[0]);\n return inMemory[0];\n }\n\n // I.e. `{ publisher: newPublisherEntity }` cannot already exist in the database, so skip the `em.find`.\n const hasNewEntityParam = Object.values(where).some((value) => isEntity(value) && value.isNewEntity);\n if (hasNewEntityParam) continue;\n\n const entities = onlySoftDeleted(\n await (em as any).find(constructor, newWhereEqual(meta, where), { softDeletes: \"include\" }),\n ) as T[];\n if (entities.length > 1) {\n throw new TooManyError(`Found more than one existing ${meta.type} with ${whereAsString(where)}`);\n } else if (entities.length === 1) {\n resurrectIfSoftDeleted(entities[0]);\n return entities[0];\n }\n }\n return undefined;\n}\n\n/** Returns soft-deleted entities that are safe for upsert to resurrect. */\nfunction onlySoftDeleted<T extends Entity>(entities: T[]): T[] {\n return entities.filter(\n (entity) => !entity.isDeletedEntity && (entity as T & { isSoftDeletedEntity?: boolean }).isSoftDeletedEntity,\n );\n}\n\n/** Builds pushdown-safe where clauses from configured identity fields. */\nfunction getUniqueByCandidates(meta: EntityMetadata, input: object): object[] {\n return (meta.uniqueBy ?? getBaseMeta(meta).uniqueBy ?? []).flatMap((fields) => {\n const where: Record<string, unknown> = {};\n for (const fieldName of fields) {\n const field = meta.allFields[fieldName];\n if (!field || !isQueryableIdentityField(field)) return [];\n const value = getIdentityValue(field, input);\n if (value === undefined || value === null) return [];\n where[field.fieldName] = value;\n }\n return [where];\n });\n}\n\n/** Returns the identity value from the input. */\nfunction getIdentityValue(field: Field, input: object): unknown {\n if (Object.hasOwn(input, field.fieldName)) {\n return normalizeIdentityValue((input as Record<string, unknown>)[field.fieldName]);\n }\n if (field.fieldIdName && Object.hasOwn(input, field.fieldIdName)) {\n return normalizeIdentityValue((input as Record<string, unknown>)[field.fieldIdName]);\n }\n return undefined;\n}\n\n/** Returns raw ids from relation inputs, i.e. `{ publisher: { id: \"p:1\" } }` becomes `{ publisher: \"p:1\" }`. */\nfunction normalizeIdentityValue(value: unknown): unknown {\n if (!isEntity(value) && value && typeof value === \"object\" && Object.hasOwn(value, \"id\")) {\n return (value as { id?: unknown }).id;\n }\n return value;\n}\n\n/** Returns true if Joist can push this identity field into a flat find clause. */\nfunction isQueryableIdentityField(field: Field): boolean {\n return field.kind === \"primitive\" || field.kind === \"enum\" || field.kind === \"m2o\" || field.kind === \"poly\";\n}\n\nfunction whereAsString(where: object): string {\n return Object.entries(where)\n .map(([key, value]) => `${key}=${value}`)\n .join(\", \");\n}\n\n/** Builds exact identity filters for primitives and relations. */\nexport function newWhereEqual(meta: EntityMetadata, input: object): object {\n const where: Record<string, unknown> = {};\n\n // Arrays need eq for exact equality; relations must stay as-is because eq would be a nested entity filter.\n // Unset values must match SQL NULL, e.g. publisher: undefined must not omit the publisher filter.\n for (const name of Object.keys(input)) {\n const value = (input as Record<string, unknown>)[name] ?? null;\n where[name] = meta.allFields[name]?.kind === \"primitive\" ? { eq: value } : value;\n }\n\n return where;\n}\n"],"mappings":";;;;;;AAMA,SAAgB,uBAAuB,QAAsB;CAC3D,MAAM,YAAY,YAAY,YAAY,MAAM,CAAC,CAAC,CAAC,iBAAiB;CACpE,IAAI,aAAc,OAAe,qBAC/B,SAAS,QAAQ,WAAW,KAAA,CAAS;AAEzC;;;;;AAMA,eAAsB,uBACpB,IACA,aACA,OACwB;CACxB,MAAM,OAAO,YAAY,WAAW;CACpC,MAAM,aAAa,sBAAsB,MAAM,KAAK;CACpD,KAAK,MAAM,SAAS,YAAY;EAC9B,MAAM,WAAW,gBAAgB,GAAG,eAAe,aAAoB,KAAY,CAAQ;EAC3F,IAAI,SAAS,SAAS,GACpB,MAAM,IAAI,aAAa,gCAAgC,KAAK,KAAK,QAAQ,cAAc,KAAK,GAAG;OAC1F,IAAI,SAAS,WAAW,GAAG;GAChC,uBAAuB,SAAS,EAAE;GAClC,OAAO,SAAS;EAClB;EAIA,IAD0B,OAAO,OAAO,KAAK,CAAC,CAAC,MAAM,UAAU,SAAS,KAAK,KAAK,MAAM,WACpE,GAAG;EAEvB,MAAM,WAAW,gBACf,MAAO,GAAW,KAAK,aAAa,cAAc,MAAM,KAAK,GAAG,EAAE,aAAa,UAAU,CAAC,CAC5F;EACA,IAAI,SAAS,SAAS,GACpB,MAAM,IAAI,aAAa,gCAAgC,KAAK,KAAK,QAAQ,cAAc,KAAK,GAAG;OAC1F,IAAI,SAAS,WAAW,GAAG;GAChC,uBAAuB,SAAS,EAAE;GAClC,OAAO,SAAS;EAClB;CACF;AAEF;;AAGA,SAAS,gBAAkC,UAAoB;CAC7D,OAAO,SAAS,QACb,WAAW,CAAC,OAAO,mBAAoB,OAAiD,mBAC3F;AACF;;AAGA,SAAS,sBAAsB,MAAsB,OAAyB;CAC5E,QAAQ,KAAK,YAAY,YAAY,IAAI,CAAC,CAAC,YAAY,CAAC,EAAA,CAAG,SAAS,WAAW;EAC7E,MAAM,QAAiC,CAAC;EACxC,KAAK,MAAM,aAAa,QAAQ;GAC9B,MAAM,QAAQ,KAAK,UAAU;GAC7B,IAAI,CAAC,SAAS,CAAC,yBAAyB,KAAK,GAAG,OAAO,CAAC;GACxD,MAAM,QAAQ,iBAAiB,OAAO,KAAK;GAC3C,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,OAAO,CAAC;GACnD,MAAM,MAAM,aAAa;EAC3B;EACA,OAAO,CAAC,KAAK;CACf,CAAC;AACH;;AAGA,SAAS,iBAAiB,OAAc,OAAwB;CAC9D,IAAI,OAAO,OAAO,OAAO,MAAM,SAAS,GACtC,OAAO,uBAAwB,MAAkC,MAAM,UAAU;CAEnF,IAAI,MAAM,eAAe,OAAO,OAAO,OAAO,MAAM,WAAW,GAC7D,OAAO,uBAAwB,MAAkC,MAAM,YAAY;AAGvF;;AAGA,SAAS,uBAAuB,OAAyB;CACvD,IAAI,CAAC,SAAS,KAAK,KAAK,SAAS,OAAO,UAAU,YAAY,OAAO,OAAO,OAAO,IAAI,GACrF,OAAQ,MAA2B;CAErC,OAAO;AACT;;AAGA,SAAS,yBAAyB,OAAuB;CACvD,OAAO,MAAM,SAAS,eAAe,MAAM,SAAS,UAAU,MAAM,SAAS,SAAS,MAAM,SAAS;AACvG;AAEA,SAAS,cAAc,OAAuB;CAC5C,OAAO,OAAO,QAAQ,KAAK,CAAC,CACzB,KAAK,CAAC,KAAK,WAAW,GAAG,IAAI,GAAG,OAAO,CAAC,CACxC,KAAK,IAAI;AACd;;AAGA,SAAgB,cAAc,MAAsB,OAAuB;CACzE,MAAM,QAAiC,CAAC;CAIxC,KAAK,MAAM,QAAQ,OAAO,KAAK,KAAK,GAAG;EACrC,MAAM,QAAS,MAAkC,SAAS;EAC1D,MAAM,QAAQ,KAAK,UAAU,KAAK,EAAE,SAAS,cAAc,EAAE,IAAI,MAAM,IAAI;CAC7E;CAEA,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "joist-core",
3
- "version": "2.4.0-next.10",
3
+ "version": "2.4.0-next.12",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "build"
43
43
  ],
44
44
  "peerDependencies": {
45
- "joist-utils": "2.4.0-next.10"
45
+ "joist-utils": "2.4.0-next.12"
46
46
  },
47
47
  "dependencies": {
48
48
  "ansis": "^4.3.1",